From 69a8829750bcbe7cb59626e504ab7120545bed2c Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Fri, 27 Feb 2026 12:58:52 +0000 Subject: [PATCH] Phase 3: Bug fixes, feature enhancements, and polish P0 Critical: presentation isolation (client scoping), storage super_admin fix, template selection in worker, IMAGE_PROVIDERS list fix. P1 High: template layout management UI (delete/filter/bulk), slide-based parsing mode, LLM model listing & connection test, settings persistence to DB (Fernet encryption), logout button. P2 Polish: storage improvements (master deck files, per-client breakdown, bulk delete, hard purge, client selector), image generation error visibility (__image_error__ badge), hamster wheel loading animation. Co-Authored-By: Claude Opus 4.6 --- backend/api/lifespan.py | 11 + backend/api/v1/admin/master_decks_router.py | 100 ++++- backend/api/v1/admin/settings_router.py | 239 +++++++++-- backend/api/v1/admin/storage_router.py | 219 +++++++++- backend/api/v1/ppt/endpoints/presentation.py | 52 ++- .../c7a3f8e21d4b_phase3_add_columns.py | 35 ++ backend/models/sql/ai_usage.py | 1 + backend/models/sql/master_deck.py | 3 + backend/models/sql/presentation.py | 3 + backend/services/image_generation_service.py | 26 +- .../services/master_deck_parser_service.py | 126 +++++- backend/services/settings_service.py | 174 ++++++++ backend/utils/process_slides.py | 4 + backend/workers/presentation_worker.py | 4 +- .../components/EditableLayoutWrapper.tsx | 42 +- .../components/HeaderNab.tsx | 27 +- .../components/LoadingSpinner.tsx | 4 +- .../generate/progress/page.tsx | 4 +- .../presentation/components/LoadingState.tsx | 5 +- .../services/api/dashboard.ts | 6 +- .../components/LoadingStates.tsx | 10 +- .../admin/clients/[id]/master-decks/page.tsx | 392 ++++++++++++++++-- .../app/admin/components/AdminSidebar.tsx | 24 +- frontend/app/admin/settings/page.tsx | 256 +++++++++--- frontend/app/admin/storage/page.tsx | 253 ++++++++++- frontend/app/globals.css | 218 ++++++++++ frontend/components/ui/hamster-loader.tsx | 44 ++ frontend/components/ui/loader.tsx | 3 +- frontend/store/slices/adminSlice.ts | 30 +- frontend/store/slices/authSlice.ts | 12 + 30 files changed, 2127 insertions(+), 200 deletions(-) create mode 100644 backend/migrations/versions/c7a3f8e21d4b_phase3_add_columns.py create mode 100644 backend/services/settings_service.py create mode 100644 frontend/components/ui/hamster-loader.tsx diff --git a/backend/api/lifespan.py b/backend/api/lifespan.py index 55194c2..eaaa8de 100644 --- a/backend/api/lifespan.py +++ b/backend/api/lifespan.py @@ -20,6 +20,17 @@ async def app_lifespan(_: FastAPI): """ os.makedirs(get_app_data_directory_env(), exist_ok=True) await create_db_and_tables() + + # Load persisted settings from database into os.environ + try: + from services.database import async_session_maker + from services.settings_service import load_settings + async with async_session_maker() as session: + await load_settings(session) + print("[Lifespan] Loaded persisted settings from database") + except Exception as e: + print(f"[Lifespan] Could not load persisted settings (non-fatal): {e}") + await check_llm_and_image_provider_api_or_model_availability() yield await close_arq_pool() diff --git a/backend/api/v1/admin/master_decks_router.py b/backend/api/v1/admin/master_decks_router.py index a7b149c..11e85c1 100644 --- a/backend/api/v1/admin/master_decks_router.py +++ b/backend/api/v1/admin/master_decks_router.py @@ -2,9 +2,9 @@ import os import shutil import uuid -from typing import Optional +from typing import List, Optional -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel from sqlalchemy import select @@ -41,6 +41,10 @@ class LayoutUpdate(BaseModel): react_code: Optional[str] = None +class LayoutBulkDelete(BaseModel): + indices: List[int] + + # --- Helpers --- @@ -60,6 +64,7 @@ async def _list_decks(client_id: uuid.UUID, include_inactive: bool, session: Asy "name": d.name, "description": d.description, "thumbnail_path": d.thumbnail_path, + "parse_mode": getattr(d, "parse_mode", None) or "slides", "parse_status": d.parse_status, "is_active": d.is_active, "layouts": d.layouts, @@ -99,6 +104,7 @@ async def list_master_decks( async def upload_master_deck( client_id: uuid.UUID, file: UploadFile = File(...), + parse_mode: str = Query("slides", description="Parse mode: 'slides' (default) or 'layouts'"), admin: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): @@ -112,6 +118,9 @@ async def upload_master_deck( if hasattr(file, "size") and file.size and file.size > 100 * 1024 * 1024: raise HTTPException(status_code=400, detail="File too large (max 100 MB)") + if parse_mode not in ("slides", "layouts"): + parse_mode = "slides" + deck_id = uuid.uuid4() deck_path = _deck_dir(client_id, deck_id) os.makedirs(deck_path, exist_ok=True) @@ -127,6 +136,7 @@ async def upload_master_deck( client_id=client_id, name=os.path.splitext(original_name)[0], original_file_path=file_path, + parse_mode=parse_mode, parse_status="pending", is_active=True, ) @@ -253,9 +263,91 @@ async def update_layout( return {"ok": True, "layout_index": layout_index} +@MASTER_DECKS_ROUTER.delete("/master-decks/{deck_id}/layouts/{layout_index}") +async def delete_layout( + deck_id: uuid.UUID, + layout_index: int, + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Delete a single layout from a master deck by index.""" + deck = await session.get(MasterDeckModel, deck_id) + if not deck: + raise HTTPException(status_code=404, detail="Master deck not found") + + await check_team_admin(admin, deck.client_id, session) + + if not deck.layouts or layout_index < 0 or layout_index >= len(deck.layouts): + raise HTTPException(status_code=404, detail="Layout not found at index") + + deck.layouts.pop(layout_index) + # Re-index remaining layouts + for i, layout in enumerate(deck.layouts): + layout["index"] = i + + from sqlalchemy.orm.attributes import flag_modified + flag_modified(deck, "layouts") + await session.commit() + + # Re-register template with remaining layouts + await _re_register_template(deck, session) + + return {"ok": True, "remaining_layouts": len(deck.layouts)} + + +@MASTER_DECKS_ROUTER.post("/master-decks/{deck_id}/layouts/bulk-delete") +async def bulk_delete_layouts( + deck_id: uuid.UUID, + body: LayoutBulkDelete, + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Bulk delete layouts from a master deck by indices.""" + deck = await session.get(MasterDeckModel, deck_id) + if not deck: + raise HTTPException(status_code=404, detail="Master deck not found") + + await check_team_admin(admin, deck.client_id, session) + + if not deck.layouts: + raise HTTPException(status_code=400, detail="No layouts to delete") + + # Validate indices + valid_indices = sorted(set(i for i in body.indices if 0 <= i < len(deck.layouts)), reverse=True) + if not valid_indices: + raise HTTPException(status_code=400, detail="No valid indices provided") + + # Remove from highest index first to preserve lower indices + for idx in valid_indices: + deck.layouts.pop(idx) + + # Re-index remaining layouts + for i, layout in enumerate(deck.layouts): + layout["index"] = i + + from sqlalchemy.orm.attributes import flag_modified + flag_modified(deck, "layouts") + await session.commit() + + # Re-register template with remaining layouts + await _re_register_template(deck, session) + + return {"ok": True, "deleted": len(valid_indices), "remaining_layouts": len(deck.layouts)} + + +async def _re_register_template(deck: MasterDeckModel, session: AsyncSession): + """Re-register a master deck's layouts as a custom template after mutations.""" + try: + from services.master_deck_parser_service import _register_as_template + await _register_as_template(deck.id, deck.name, deck.layouts or [], session) + except Exception as e: + print(f"[master_decks] Failed to re-register template: {e}") + + @MASTER_DECKS_ROUTER.post("/master-decks/{deck_id}/reparse") async def reparse_master_deck( deck_id: uuid.UUID, + parse_mode: Optional[str] = Query(None, description="Parse mode: 'slides' or 'layouts'. Defaults to deck's current mode."), admin: UserModel = Depends(require_client_admin), session: AsyncSession = Depends(get_async_session), ): @@ -268,6 +360,10 @@ async def reparse_master_deck( if deck.parse_status == "processing": raise HTTPException(status_code=409, detail="Deck is already being parsed") + # Update parse mode if provided + if parse_mode and parse_mode in ("slides", "layouts"): + deck.parse_mode = parse_mode + deck.parse_status = "pending" await session.commit() diff --git a/backend/api/v1/admin/settings_router.py b/backend/api/v1/admin/settings_router.py index dabdaf5..5bcd421 100644 --- a/backend/api/v1/admin/settings_router.py +++ b/backend/api/v1/admin/settings_router.py @@ -1,8 +1,10 @@ """Admin router for system settings — LLM and image provider configuration.""" +import asyncio import os -from typing import Optional +import time +from typing import Optional, List -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession @@ -32,7 +34,7 @@ class SettingsUpdate(BaseModel): LLM_PROVIDERS = ["anthropic", "openai", "google", "ollama", "custom"] -IMAGE_PROVIDERS = ["google", "dall-e-3", "gpt-image-1.5", "pexels", "pixabay", "comfyui"] +IMAGE_PROVIDERS = ["nanobanana_pro", "gemini_flash", "dall-e-3", "gpt-image-1.5", "pexels", "pixabay", "comfyui"] @SETTINGS_ROUTER.get("/settings") @@ -60,55 +62,67 @@ async def get_settings( async def update_settings( body: SettingsUpdate, admin: UserModel = Depends(require_client_admin), - _session: AsyncSession = Depends(get_async_session), + session: AsyncSession = Depends(get_async_session), ): - """Update system settings (runtime env vars). Changes don't persist across restarts.""" + """Update system settings. Changes apply immediately and persist to database.""" if admin.role != "super_admin": raise HTTPException(status_code=403, detail="Super admin only") - changed = [] + # Validate providers + if body.llm_provider is not None and body.llm_provider not in LLM_PROVIDERS: + raise HTTPException(status_code=400, detail=f"Invalid LLM provider: {body.llm_provider}") + if body.image_provider is not None and body.image_provider not in IMAGE_PROVIDERS: + raise HTTPException(status_code=400, detail=f"Invalid image provider: {body.image_provider}") - if body.llm_provider is not None: - if body.llm_provider not in LLM_PROVIDERS: - raise HTTPException(status_code=400, detail=f"Invalid LLM provider: {body.llm_provider}") - os.environ["LLM"] = body.llm_provider - changed.append("llm_provider") - - if body.llm_model is not None: - provider = body.llm_provider or os.getenv("LLM", "anthropic") - model_env_map = { - "anthropic": "ANTHROPIC_MODEL", - "openai": "OPENAI_MODEL", - "google": "GOOGLE_MODEL", - "ollama": "OLLAMA_MODEL", - "custom": "CUSTOM_MODEL", - } - env_key = model_env_map.get(provider) - if env_key: - os.environ[env_key] = body.llm_model - changed.append("llm_model") - - if body.image_provider is not None: - if body.image_provider not in IMAGE_PROVIDERS: - raise HTTPException(status_code=400, detail=f"Invalid image provider: {body.image_provider}") - os.environ["IMAGE_PROVIDER"] = body.image_provider - changed.append("image_provider") - - if body.anthropic_api_key is not None: - os.environ["ANTHROPIC_API_KEY"] = body.anthropic_api_key - changed.append("anthropic_api_key") - - if body.openai_api_key is not None: - os.environ["OPENAI_API_KEY"] = body.openai_api_key - changed.append("openai_api_key") - - if body.google_api_key is not None: - os.environ["GOOGLE_API_KEY"] = body.google_api_key - changed.append("google_api_key") + from services.settings_service import save_settings + data = body.model_dump(exclude_none=True) + changed = await save_settings(session, data) return {"ok": True, "changed": changed} +@SETTINGS_ROUTER.get("/settings/models") +async def list_models( + provider: str = Query(..., description="Provider name"), + admin: UserModel = Depends(require_client_admin), + _session: AsyncSession = Depends(get_async_session), +): + """List available models for a given provider.""" + if admin.role != "super_admin": + raise HTTPException(status_code=403, detail="Super admin only") + + try: + models = await _fetch_models_for_provider(provider) + return {"provider": provider, "models": models} + except Exception as e: + return {"provider": provider, "models": [], "error": str(e)} + + +class ConnectionTestRequest(BaseModel): + provider: str + api_key: Optional[str] = None + + +@SETTINGS_ROUTER.post("/settings/test-connection") +async def test_connection( + body: ConnectionTestRequest, + admin: UserModel = Depends(require_client_admin), + _session: AsyncSession = Depends(get_async_session), +): + """Test connectivity to an LLM/API provider.""" + if admin.role != "super_admin": + raise HTTPException(status_code=403, detail="Super admin only") + + start = time.time() + try: + ok, error = await _test_provider_connection(body.provider, body.api_key) + latency_ms = int((time.time() - start) * 1000) + return {"ok": ok, "error": error, "latency_ms": latency_ms} + except Exception as e: + latency_ms = int((time.time() - start) * 1000) + return {"ok": False, "error": str(e), "latency_ms": latency_ms} + + def _get_current_model() -> str: provider = os.getenv("LLM", "anthropic") model_env_map = { @@ -126,3 +140,142 @@ def _get_current_model() -> str: } env_key = model_env_map.get(provider, "ANTHROPIC_MODEL") return os.getenv(env_key, defaults.get(provider, "")) + + +# --- Known model lists (for providers without a list API) --- + +ANTHROPIC_MODELS = [ + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-sonnet-4-5-20250929", + "claude-haiku-4-5-20251001", + "claude-sonnet-4-5-20250514", + "claude-3-5-haiku-20241022", +] + +GOOGLE_MODELS = [ + "models/gemini-2.5-flash", + "models/gemini-2.5-pro", + "models/gemini-2.0-flash", + "models/gemini-2.0-flash-lite", + "models/gemini-1.5-flash", + "models/gemini-1.5-pro", +] + + +async def _fetch_models_for_provider(provider: str) -> List[str]: + """Return a list of model IDs for the given provider.""" + if provider == "anthropic": + return ANTHROPIC_MODELS + + if provider == "google": + # Try listing via API, fallback to hardcoded + key = os.getenv("GOOGLE_API_KEY") + if key: + try: + def _list_google(): + import google.genai as genai + client = genai.Client(api_key=key) + result = [] + for m in client.models.list(): + name = getattr(m, "name", str(m)) + if "gemini" in name.lower(): + result.append(name) + return sorted(result) + return await asyncio.to_thread(_list_google) + except Exception: + pass + return GOOGLE_MODELS + + if provider == "openai": + key = os.getenv("OPENAI_API_KEY") + if key: + try: + def _list_openai(): + from openai import OpenAI + client = OpenAI(api_key=key) + models = client.models.list() + return sorted([m.id for m in models.data if "gpt" in m.id or "o1" in m.id or "o3" in m.id or "o4" in m.id]) + return await asyncio.to_thread(_list_openai) + except Exception: + pass + return ["gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini", "o3", "o4-mini"] + + if provider == "ollama": + try: + import httpx + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(os.getenv("OLLAMA_URL", "http://localhost:11434") + "/api/tags") + if resp.status_code == 200: + data = resp.json() + return [m["name"] for m in data.get("models", [])] + except Exception: + pass + return [] + + return [] + + +async def _test_provider_connection(provider: str, api_key: Optional[str] = None) -> tuple: + """Test provider connection. Returns (ok: bool, error: str | None).""" + if provider == "anthropic": + key = api_key or os.getenv("ANTHROPIC_API_KEY") + if not key: + return False, "No API key configured" + try: + def _test(): + import anthropic + client = anthropic.Anthropic(api_key=key) + client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=5, + messages=[{"role": "user", "content": "hi"}], + ) + return True + await asyncio.to_thread(_test) + return True, None + except Exception as e: + return False, str(e) + + if provider == "openai": + key = api_key or os.getenv("OPENAI_API_KEY") + if not key: + return False, "No API key configured" + try: + def _test(): + from openai import OpenAI + client = OpenAI(api_key=key) + client.models.list() + return True + await asyncio.to_thread(_test) + return True, None + except Exception as e: + return False, str(e) + + if provider == "google": + key = api_key or os.getenv("GOOGLE_API_KEY") + if not key: + return False, "No API key configured" + try: + def _test(): + import google.genai as genai + client = genai.Client(api_key=key) + list(client.models.list())[:1] + return True + await asyncio.to_thread(_test) + return True, None + except Exception as e: + return False, str(e) + + if provider == "ollama": + try: + import httpx + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(os.getenv("OLLAMA_URL", "http://localhost:11434") + "/api/tags") + if resp.status_code == 200: + return True, None + return False, f"HTTP {resp.status_code}" + except Exception as e: + return False, str(e) + + return False, f"Unknown provider: {provider}" diff --git a/backend/api/v1/admin/storage_router.py b/backend/api/v1/admin/storage_router.py index e5d80ee..472ed96 100644 --- a/backend/api/v1/admin/storage_router.py +++ b/backend/api/v1/admin/storage_router.py @@ -1,13 +1,16 @@ """Admin router for storage management — list, download, delete presentations.""" import os import uuid -from typing import Optional +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import FileResponse +from pydantic import BaseModel from sqlalchemy import func, select, and_ from sqlalchemy.ext.asyncio import AsyncSession +from models.sql.client import ClientModel +from models.sql.master_deck import MasterDeckModel from models.sql.presentation import PresentationModel from models.sql.user import UserModel from services import audit_service @@ -71,10 +74,59 @@ async def storage_summary( total_files += 1 total_size_bytes += os.path.getsize(path) + # Count soft-deleted presentations + deleted_filters = [PresentationModel.deleted_at.isnot(None)] + if cf is not None: + deleted_filters.append(cf) + deleted_count_q = select(func.count()).select_from(PresentationModel).where(and_(*deleted_filters)) + total_deleted = (await session.execute(deleted_count_q)).scalar() or 0 + + # Scan master decks + deck_filters = [] + if cf is not None: + # Reuse client filter but on MasterDeckModel + if client_id: + deck_filters.append(MasterDeckModel.client_id == client_id) + elif admin.role != "super_admin": + cids = await get_accessible_client_ids(admin, session) + if cids: + deck_filters.append(MasterDeckModel.client_id.in_(cids)) + + deck_count_q = select(func.count()).select_from(MasterDeckModel) + if deck_filters: + deck_count_q = deck_count_q.where(and_(*deck_filters)) + total_master_decks = (await session.execute(deck_count_q)).scalar() or 0 + + deck_q = select(MasterDeckModel) + if deck_filters: + deck_q = deck_q.where(and_(*deck_filters)) + deck_result = await session.execute(deck_q) + decks = deck_result.scalars().all() + + master_deck_files = 0 + master_deck_size = 0 + for deck in decks: + if deck.original_file_path and os.path.isfile(deck.original_file_path): + master_deck_files += 1 + master_deck_size += os.path.getsize(deck.original_file_path) + if deck.thumbnail_path and os.path.isfile(deck.thumbnail_path): + master_deck_files += 1 + master_deck_size += os.path.getsize(deck.thumbnail_path) + if deck.layouts: + for layout in deck.layouts: + sp = layout.get("screenshot_path") + if sp and os.path.isfile(sp): + master_deck_files += 1 + master_deck_size += os.path.getsize(sp) + return { "total_presentations": total_presentations, "total_files": total_files, "total_size_bytes": total_size_bytes, + "total_deleted": total_deleted, + "total_master_decks": total_master_decks, + "master_deck_files": master_deck_files, + "master_deck_size_bytes": master_deck_size, } @@ -189,3 +241,168 @@ async def delete_presentation_storage( ) return {"ok": True} + + +class BulkDeleteRequest(BaseModel): + ids: List[uuid.UUID] + + +@STORAGE_ROUTER.get("/storage/breakdown") +async def storage_breakdown( + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Per-client storage breakdown.""" + # Only super_admin can see breakdown of all clients + if admin.role != "super_admin": + raise HTTPException(status_code=403, detail="Super admin only") + + # Get all clients + clients_result = await session.execute(select(ClientModel)) + clients = clients_result.scalars().all() + + breakdown = [] + for client in clients: + cf = PresentationModel.client_id == client.id + + # Count presentations + count_q = select(func.count()).select_from(PresentationModel).where( + and_(PresentationModel.deleted_at.is_(None), cf) + ) + pres_count = (await session.execute(count_q)).scalar() or 0 + + # Presentation file sizes + paths_q = select(PresentationModel.file_paths).where( + and_(PresentationModel.deleted_at.is_(None), PresentationModel.file_paths.isnot(None), cf) + ) + result = await session.execute(paths_q) + all_paths = result.scalars().all() + pres_files = 0 + pres_size = 0 + for file_paths in all_paths: + if not file_paths: + continue + for path in file_paths: + if path and os.path.isfile(path): + pres_files += 1 + pres_size += os.path.getsize(path) + + # Master deck count & size + deck_q = select(MasterDeckModel).where(MasterDeckModel.client_id == client.id) + deck_result = await session.execute(deck_q) + decks = deck_result.scalars().all() + deck_files = 0 + deck_size = 0 + for deck in decks: + if deck.original_file_path and os.path.isfile(deck.original_file_path): + deck_files += 1 + deck_size += os.path.getsize(deck.original_file_path) + if deck.thumbnail_path and os.path.isfile(deck.thumbnail_path): + deck_files += 1 + deck_size += os.path.getsize(deck.thumbnail_path) + if deck.layouts: + for layout in deck.layouts: + sp = layout.get("screenshot_path") + if sp and os.path.isfile(sp): + deck_files += 1 + deck_size += os.path.getsize(sp) + + breakdown.append({ + "client_id": str(client.id), + "client_name": client.name, + "presentations": pres_count, + "presentation_files": pres_files, + "presentation_size_bytes": pres_size, + "master_decks": len(decks), + "master_deck_files": deck_files, + "master_deck_size_bytes": deck_size, + "total_size_bytes": pres_size + deck_size, + }) + + breakdown.sort(key=lambda x: x["total_size_bytes"], reverse=True) + return breakdown + + +@STORAGE_ROUTER.post("/storage/presentations/bulk-delete") +async def bulk_delete_presentations( + body: BulkDeleteRequest, + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Soft-delete multiple presentations at once.""" + deleted_count = 0 + for pid in body.ids: + presentation = await session.get(PresentationModel, pid) + if not presentation or presentation.deleted_at: + continue + # Access check + if presentation.client_id: + cids = await get_accessible_client_ids(admin, session) + if presentation.client_id not in cids: + continue + presentation.deleted_at = get_current_utc_datetime() + deleted_count += 1 + + await session.commit() + + audit_service.log( + user_id=admin.id, + action="admin_bulk_delete", + resource_type="presentation", + details={"count": deleted_count, "ids": [str(i) for i in body.ids]}, + ) + + return {"ok": True, "deleted_count": deleted_count} + + +@STORAGE_ROUTER.post("/storage/purge") +async def purge_deleted_storage( + client_id: Optional[uuid.UUID] = Query(None), + admin: UserModel = Depends(require_client_admin), + session: AsyncSession = Depends(get_async_session), +): + """Hard-delete files for soft-deleted presentations.""" + if admin.role != "super_admin": + raise HTTPException(status_code=403, detail="Super admin only") + + filters = [PresentationModel.deleted_at.isnot(None)] + if client_id: + filters.append(PresentationModel.client_id == client_id) + + stmt = select(PresentationModel).where(and_(*filters)) + result = await session.execute(stmt) + deleted_presentations = result.scalars().all() + + purged_files = 0 + purged_bytes = 0 + purged_presentations = 0 + + for p in deleted_presentations: + if p.file_paths: + for path in p.file_paths: + if path and os.path.isfile(path): + try: + size = os.path.getsize(path) + os.remove(path) + purged_files += 1 + purged_bytes += size + except OSError: + pass + p.file_paths = [] + purged_presentations += 1 + + await session.commit() + + audit_service.log( + user_id=admin.id, + action="admin_purge", + resource_type="storage", + details={"presentations": purged_presentations, "files": purged_files, "bytes": purged_bytes}, + ) + + return { + "ok": True, + "purged_presentations": purged_presentations, + "purged_files": purged_files, + "purged_bytes": purged_bytes, + } diff --git a/backend/api/v1/ppt/endpoints/presentation.py b/backend/api/v1/ppt/endpoints/presentation.py index ec5e72b..a9bfd09 100644 --- a/backend/api/v1/ppt/endpoints/presentation.py +++ b/backend/api/v1/ppt/endpoints/presentation.py @@ -7,12 +7,13 @@ import random import traceback from typing import Annotated, List, Literal, Optional, Tuple import dirtyjson -from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path +from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Path, Query from fastapi.responses import StreamingResponse -from sqlalchemy import delete +from sqlalchemy import delete, and_ from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select from constants.presentation import DEFAULT_TEMPLATES +from services.access_service import get_accessible_client_ids from enums.webhook_event import WebhookEvent from models.api_error_model import APIErrorModel from models.generate_presentation_request import GeneratePresentationRequest @@ -75,10 +76,31 @@ PRESENTATION_ROUTER = APIRouter(prefix="/presentation", tags=["Presentation"]) @PRESENTATION_ROUTER.get("/all", response_model=List[PresentationWithSlides]) async def get_all_presentations( - _current_user: UserModel = Depends(get_current_user), + client_id: Optional[uuid.UUID] = Query(None), + current_user: UserModel = Depends(get_current_user), sql_session: AsyncSession = Depends(get_async_session), ): - presentations_with_slides = [] + # Build filters: always exclude soft-deleted + filters = [PresentationModel.deleted_at.is_(None)] + + if client_id: + # Explicit client_id filter — verify access + accessible = await get_accessible_client_ids(current_user, sql_session) + if client_id not in accessible: + raise HTTPException(403, "Access denied to this client") + filters.append(PresentationModel.client_id == client_id) + elif current_user.role == "super_admin": + pass # No restriction + else: + # Non-admin: show only presentations from accessible clients + own presentations + accessible = await get_accessible_client_ids(current_user, sql_session) + if accessible: + filters.append( + (PresentationModel.client_id.in_(accessible)) + | (PresentationModel.owner_id == current_user.id) + ) + else: + filters.append(PresentationModel.owner_id == current_user.id) query = ( select(PresentationModel, SlideModel) @@ -86,6 +108,7 @@ async def get_all_presentations( SlideModel, (SlideModel.presentation == PresentationModel.id) & (SlideModel.index == 0), ) + .where(and_(*filters)) .order_by(PresentationModel.created_at.desc()) ) @@ -159,6 +182,12 @@ async def create_presentation( presentation_id = uuid.uuid4() + # Resolve user's client_id from team memberships + user_client_id = None + accessible = await get_accessible_client_ids(current_user, sql_session) + if accessible: + user_client_id = accessible[0] # Primary client + presentation = PresentationModel( id=presentation_id, content=content, @@ -172,6 +201,7 @@ async def create_presentation( include_title_slide=include_title_slide, web_search=web_search, owner_id=current_user.id, + client_id=user_client_id, ) sql_session.add(presentation) @@ -846,6 +876,12 @@ async def generate_presentation_async( try: (presentation_id,) = await check_if_api_request_is_valid(request, sql_session) + # Resolve user's client_id from team memberships + user_client_id = None + accessible = await get_accessible_client_ids(_current_user, sql_session) + if accessible: + user_client_id = accessible[0] + # Create a lightweight presentation record so the worker can load it presentation = PresentationModel( id=presentation_id, @@ -855,6 +891,12 @@ async def generate_presentation_async( tone=request.tone.value, verbosity=request.verbosity.value, instructions=request.instructions, + include_table_of_contents=request.include_table_of_contents, + include_title_slide=request.include_title_slide, + web_search=request.web_search, + template_name=request.template, + owner_id=_current_user.id, + client_id=user_client_id, ) sql_session.add(presentation) @@ -874,7 +916,7 @@ async def generate_presentation_async( job = JobModel( user_id=_current_user.id, - client_id=getattr(_current_user, "default_client_id", _current_user.id), + client_id=user_client_id, presentation_id=presentation_id, job_type="generate_presentation", status="queued", diff --git a/backend/migrations/versions/c7a3f8e21d4b_phase3_add_columns.py b/backend/migrations/versions/c7a3f8e21d4b_phase3_add_columns.py new file mode 100644 index 0000000..c2c1d19 --- /dev/null +++ b/backend/migrations/versions/c7a3f8e21d4b_phase3_add_columns.py @@ -0,0 +1,35 @@ +"""phase3: add parse_mode, template_name, error_details columns + +Revision ID: c7a3f8e21d4b +Revises: 513b48dbb16c +Create Date: 2026-02-27 13:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c7a3f8e21d4b' +down_revision: Union[str, None] = '513b48dbb16c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add parse_mode to master_decks (P1-2: slide-based parsing mode) + op.add_column('master_decks', sa.Column('parse_mode', sa.String(), nullable=True)) + + # Add template_name to presentations (P0-3: template selection in worker) + op.add_column('presentations', sa.Column('template_name', sa.String(), nullable=True)) + + # Add error_details to ai_usage_logs (P2-2: image generation error visibility) + op.add_column('ai_usage_logs', sa.Column('error_details', sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('ai_usage_logs', 'error_details') + op.drop_column('presentations', 'template_name') + op.drop_column('master_decks', 'parse_mode') diff --git a/backend/models/sql/ai_usage.py b/backend/models/sql/ai_usage.py index e49a4d1..4163c3f 100644 --- a/backend/models/sql/ai_usage.py +++ b/backend/models/sql/ai_usage.py @@ -29,6 +29,7 @@ class AIUsageModel(SQLModel, table=True): output_tokens: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None) total_tokens: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None) duration_ms: Optional[int] = Field(sa_column=Column(Integer, nullable=True), default=None) + error_details: Optional[str] = Field(sa_column=Column(String, nullable=True), default=None) created_at: datetime = Field( sa_column=Column( DateTime(timezone=True), nullable=False, default=get_current_utc_datetime diff --git a/backend/models/sql/master_deck.py b/backend/models/sql/master_deck.py index a5960cf..2b6465b 100644 --- a/backend/models/sql/master_deck.py +++ b/backend/models/sql/master_deck.py @@ -21,6 +21,9 @@ class MasterDeckModel(SQLModel, table=True): ) parsed_config: Optional[dict] = Field(sa_column=Column(JSON), default=None) layouts: Optional[list] = Field(sa_column=Column(JSON), default=None) + parse_mode: Optional[str] = Field( + sa_column=Column(String, nullable=True), default="slides" + ) # "slides" (actual slides, default) or "layouts" (all slideLayouts) parse_status: str = Field(default="pending") # pending, processing, completed, failed is_active: bool = Field(sa_column=Column(Boolean, default=True, nullable=False)) created_at: datetime = Field( diff --git a/backend/models/sql/presentation.py b/backend/models/sql/presentation.py index 9a3e8d7..e899c93 100644 --- a/backend/models/sql/presentation.py +++ b/backend/models/sql/presentation.py @@ -56,6 +56,9 @@ class PresentationModel(SQLModel, table=True): review_comment: Optional[str] = Field( sa_column=Column(String, nullable=True), default=None ) + template_name: Optional[str] = Field( + sa_column=Column(String, nullable=True), default=None + ) # e.g. "general", "modern", "custom-{uuid}" source_type: Optional[str] = Field( sa_column=Column(String, nullable=True), default=None ) # brief, url, manual diff --git a/backend/services/image_generation_service.py b/backend/services/image_generation_service.py index f9ec120..b17ae5e 100644 --- a/backend/services/image_generation_service.py +++ b/backend/services/image_generation_service.py @@ -101,7 +101,31 @@ class ImageGenerationService: raise Exception(f"Image not found at {image_path}") except Exception as e: - print(f"Error generating image: {e}") + error_msg = str(e) + print(f"Error generating image: {error_msg}") + # Log to AI usage asynchronously (fire-and-forget) + try: + import asyncio + from services.database import async_session_maker + from models.sql.ai_usage import AIUsageModel + + async def _log_image_error(): + try: + async with async_session_maker() as sess: + usage = AIUsageModel( + provider=os.getenv("IMAGE_PROVIDER", "unknown"), + model="image_generation", + call_type="image_generation_error", + error_details=error_msg[:500], + ) + sess.add(usage) + await sess.commit() + except Exception: + pass + + asyncio.create_task(_log_image_error()) + except Exception: + pass return "/static/images/placeholder.jpg" async def generate_image_openai( diff --git a/backend/services/master_deck_parser_service.py b/backend/services/master_deck_parser_service.py index 465ace8..d2606c4 100644 --- a/backend/services/master_deck_parser_service.py +++ b/backend/services/master_deck_parser_service.py @@ -94,6 +94,96 @@ def _extract_slide_layout_xmls(pptx_path: str, temp_dir: str) -> List[dict]: return layouts +def _extract_slides_with_layout_info(pptx_path: str, temp_dir: str) -> List[dict]: + """Extract actual slide XMLs with their associated layout name. + + Each slide in ppt/slides/ has a .rels file that references which + slideLayout it uses. This gives us 1:1 mapping between slides and + screenshots (since screenshots are generated from actual slides). + """ + extract_dir = os.path.join(temp_dir, "pptx_extract") + if not os.path.exists(extract_dir): + with zipfile.ZipFile(pptx_path, "r") as zf: + zf.extractall(extract_dir) + + slides_dir = os.path.join(extract_dir, "ppt", "slides") + if not os.path.exists(slides_dir): + return [] + + slide_files = sorted( + [f for f in os.listdir(slides_dir) if f.startswith("slide") and f.endswith(".xml")], + key=lambda x: int(x.replace("slide", "").replace(".xml", "")), + ) + + # Pre-load slideLayout names by filename for fast lookup + layout_names_by_file = {} + layouts_dir = os.path.join(extract_dir, "ppt", "slideLayouts") + if os.path.exists(layouts_dir): + for lf in os.listdir(layouts_dir): + if not lf.endswith(".xml"): + continue + path = os.path.join(layouts_dir, lf) + try: + with open(path, "r", encoding="utf-8") as f: + root = ET.fromstring(f.read()) + cSld = root.find("p:cSld", NS) + if cSld is not None and cSld.get("name"): + layout_names_by_file[lf] = cSld.get("name") + else: + layout_names_by_file[lf] = lf.replace(".xml", "") + except Exception: + layout_names_by_file[lf] = lf.replace(".xml", "") + + RELS_NS = {"r": "http://schemas.openxmlformats.org/package/2006/relationships"} + + slides = [] + for sf in slide_files: + slide_path = os.path.join(slides_dir, sf) + with open(slide_path, "r", encoding="utf-8") as f: + xml_content = f.read() + + # Resolve layout name from .rels file + layout_name = sf.replace(".xml", "") + rels_path = os.path.join(slides_dir, "_rels", sf + ".rels") + if os.path.exists(rels_path): + try: + with open(rels_path, "r", encoding="utf-8") as f: + rels_root = ET.fromstring(f.read()) + for rel in rels_root.findall("r:Relationship", RELS_NS): + # Fallback: try without namespace + target = rel.get("Target", "") + rel_type = rel.get("Type", "") + if "slideLayout" in rel_type or "slideLayout" in target: + # Target is like "../slideLayouts/slideLayout2.xml" + layout_file = target.split("/")[-1] + if layout_file in layout_names_by_file: + layout_name = layout_names_by_file[layout_file] + else: + layout_name = layout_file.replace(".xml", "") + break + # If namespace didn't match, try without namespace + if layout_name == sf.replace(".xml", ""): + for rel in rels_root.iter(): + target = rel.get("Target", "") + if "slideLayout" in target: + layout_file = target.split("/")[-1] + if layout_file in layout_names_by_file: + layout_name = layout_names_by_file[layout_file] + else: + layout_name = layout_file.replace(".xml", "") + break + except Exception: + pass + + slides.append({ + "filename": sf, + "layout_name": layout_name, + "xml_content": xml_content, + }) + + return slides + + def _extract_theme_info(pptx_path: str, temp_dir: str) -> dict: """Extract theme colors and font scheme from the PPTX theme XML.""" extract_dir = os.path.join(temp_dir, "pptx_extract") @@ -371,16 +461,27 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: raise ValueError("Deck not found") pptx_path = deck.original_file_path client_id = deck.client_id + parse_mode = getattr(deck, "parse_mode", None) or "slides" if not os.path.exists(pptx_path): raise FileNotFoundError(f"PPTX file not found: {pptx_path}") with tempfile.TemporaryDirectory() as temp_dir: - # 1. Extract slide XMLs (actual slides, not layouts) for screenshots + # 1. Extract slide XMLs (actual slides) — always needed for font collection slide_xmls = _extract_slide_xmls(pptx_path, temp_dir) - # 2. Extract slide layout XMLs from ppt/slideLayouts/ - layout_metas = _extract_slide_layout_xmls(pptx_path, temp_dir) + # 2. Choose primary source based on parse_mode + if parse_mode == "layouts": + # Legacy mode: use all slideLayout XMLs from ppt/slideLayouts/ + primary_metas = _extract_slide_layout_xmls(pptx_path, temp_dir) + print(f"[MasterDeckParser] Mode=layouts: {len(primary_metas)} slideLayouts") + else: + # Default "slides" mode: use actual slides with layout name resolution + primary_metas = _extract_slides_with_layout_info(pptx_path, temp_dir) + print(f"[MasterDeckParser] Mode=slides: {len(primary_metas)} actual slides") + + # Also get layout XMLs for font extraction even in slides mode + layout_metas_for_fonts = _extract_slide_layout_xmls(pptx_path, temp_dir) # 3. Extract theme info theme_info = _extract_theme_info(pptx_path, temp_dir) @@ -414,21 +515,21 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: # 5. Collect all fonts used all_fonts = set() - for lm in layout_metas: + for lm in layout_metas_for_fonts: raw = extract_fonts_from_oxml(lm["xml_content"]) all_fonts.update(normalize_font_family_name(f) for f in raw if f) for sx in slide_xmls: raw = extract_fonts_from_oxml(sx) all_fonts.update(normalize_font_family_name(f) for f in raw if f) - # 6. Process each slide layout through LLM pipeline + # 6. Process each item through LLM pipeline llm_provider = _detect_llm_provider() layouts_result = [] - llm_layouts = min(len(layout_metas), len(screenshots)) + llm_count = min(len(primary_metas), len(screenshots)) print(f"[MasterDeckParser] LLM provider: {llm_provider['provider'] if llm_provider else 'NONE'}") - print(f"[MasterDeckParser] Processing {len(layout_metas)} layouts, {llm_layouts} with screenshots for LLM") + print(f"[MasterDeckParser] Processing {len(primary_metas)} items, {llm_count} with screenshots for LLM") - for idx, lm in enumerate(layout_metas): + for idx, lm in enumerate(primary_metas): layout_entry = { "index": idx, "layout_name": lm["layout_name"], @@ -445,7 +546,7 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: # Run LLM pipeline if provider available and we have a screenshot if llm_provider and idx < len(screenshots) and os.path.exists(screenshots[idx]): try: - print(f"[MasterDeckParser] Layout {idx + 1}/{llm_layouts}: {lm['layout_name']} — generating HTML...") + print(f"[MasterDeckParser] Layout {idx + 1}/{llm_count}: {lm['layout_name']} — generating HTML...") with open(screenshots[idx], "rb") as img_f: img_b64 = base64.b64encode(img_f.read()).decode("utf-8") @@ -456,13 +557,13 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: html = html.replace("```html", "").replace("```", "") layout_entry["html"] = html - print(f"[MasterDeckParser] Layout {idx + 1}/{llm_layouts}: {lm['layout_name']} — generating React...") + print(f"[MasterDeckParser] Layout {idx + 1}/{llm_count}: {lm['layout_name']} — generating React...") react_code = await _llm_generate_react( llm_provider, html, img_b64, ) react_code = react_code.replace("```tsx", "").replace("```", "") layout_entry["react_code"] = react_code - print(f"[MasterDeckParser] Layout {idx + 1}/{llm_layouts}: {lm['layout_name']} — done ({len(react_code)} chars)") + print(f"[MasterDeckParser] Layout {idx + 1}/{llm_count}: {lm['layout_name']} — done ({len(react_code)} chars)") except Exception as e: print(f"[MasterDeckParser] LLM FAILED for layout {idx} ({lm['layout_name']}): {e}") @@ -475,7 +576,8 @@ async def _do_parse(deck_id: uuid.UUID) -> dict: parsed_config = { "theme": theme_info, "total_slides": len(slide_xmls), - "total_layouts": len(layout_metas), + "total_layouts": len(layout_metas_for_fonts), + "parse_mode": parse_mode, "fonts": sorted(all_fonts), } diff --git a/backend/services/settings_service.py b/backend/services/settings_service.py new file mode 100644 index 0000000..046b043 --- /dev/null +++ b/backend/services/settings_service.py @@ -0,0 +1,174 @@ +"""Service for persisting and loading system settings from the database. + +Settings are stored in KeyValueSqlModel with key="system_settings". +API keys are stored separately with key="api_keys" and encrypted at rest +using Fernet if SETTINGS_ENCRYPTION_KEY is set, otherwise stored in plain text. + +On startup, persisted settings are loaded into os.environ so the rest of the +application can read them transparently. +""" +import os +from typing import Optional + +try: + from cryptography.fernet import Fernet + HAS_FERNET = True +except ImportError: + HAS_FERNET = False +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from models.sql.key_value import KeyValueSqlModel + +SETTINGS_KEY = "system_settings" +API_KEYS_KEY = "api_keys" + +# Env vars that map to settings +SETTING_ENV_MAP = { + "llm_provider": "LLM", + "llm_model": None, # Handled specially per-provider + "image_provider": "IMAGE_PROVIDER", +} + +MODEL_ENV_MAP = { + "anthropic": "ANTHROPIC_MODEL", + "openai": "OPENAI_MODEL", + "google": "GOOGLE_MODEL", + "ollama": "OLLAMA_MODEL", + "custom": "CUSTOM_MODEL", +} + +API_KEY_ENV_MAP = { + "anthropic_api_key": "ANTHROPIC_API_KEY", + "openai_api_key": "OPENAI_API_KEY", + "google_api_key": "GOOGLE_API_KEY", +} + + +def _get_fernet(): + """Return Fernet cipher if encryption key is configured and cryptography is available.""" + if not HAS_FERNET: + return None + key = os.getenv("SETTINGS_ENCRYPTION_KEY") + if key: + try: + return Fernet(key.encode() if isinstance(key, str) else key) + except Exception: + pass + return None + + +def _encrypt(value: str) -> str: + f = _get_fernet() + if f: + return f.encrypt(value.encode()).decode() + return value + + +def _decrypt(value: str) -> str: + f = _get_fernet() + if f: + try: + return f.decrypt(value.encode()).decode() + except Exception: + return value # Fallback: might be plain text from before encryption was enabled + return value + + +async def _get_kv(session: AsyncSession, key: str) -> Optional[dict]: + """Get a key-value record.""" + stmt = select(KeyValueSqlModel).where(KeyValueSqlModel.key == key) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + return row.value if row else None + + +async def _set_kv(session: AsyncSession, key: str, value: dict) -> None: + """Upsert a key-value record.""" + stmt = select(KeyValueSqlModel).where(KeyValueSqlModel.key == key) + result = await session.execute(stmt) + row = result.scalar_one_or_none() + if row: + row.value = value + from sqlalchemy.orm.attributes import flag_modified + flag_modified(row, "value") + else: + row = KeyValueSqlModel(key=key, value=value) + session.add(row) + await session.commit() + + +async def load_settings(session: AsyncSession) -> dict: + """Load persisted settings from DB and apply to os.environ. + + Returns the settings dict. Falls back to env vars if nothing persisted. + """ + settings = await _get_kv(session, SETTINGS_KEY) or {} + api_keys = await _get_kv(session, API_KEYS_KEY) or {} + + # Apply settings to env + if "llm_provider" in settings: + os.environ["LLM"] = settings["llm_provider"] + + if "llm_model" in settings: + provider = settings.get("llm_provider", os.getenv("LLM", "anthropic")) + env_key = MODEL_ENV_MAP.get(provider) + if env_key: + os.environ[env_key] = settings["llm_model"] + + if "image_provider" in settings: + os.environ["IMAGE_PROVIDER"] = settings["image_provider"] + + # Apply API keys (decrypt first) + for setting_key, env_key in API_KEY_ENV_MAP.items(): + encrypted_val = api_keys.get(setting_key) + if encrypted_val: + os.environ[env_key] = _decrypt(encrypted_val) + + return settings + + +async def save_settings(session: AsyncSession, data: dict) -> list: + """Persist settings to DB and update os.environ. + + Handles both regular settings and API keys. + Returns list of changed field names. + """ + changed = [] + + # Load existing + current_settings = await _get_kv(session, SETTINGS_KEY) or {} + current_api_keys = await _get_kv(session, API_KEYS_KEY) or {} + + # Process regular settings + for field in ("llm_provider", "llm_model", "image_provider"): + if field in data and data[field] is not None: + current_settings[field] = data[field] + changed.append(field) + + # Also update env immediately + if field == "llm_provider": + os.environ["LLM"] = data[field] + elif field == "image_provider": + os.environ["IMAGE_PROVIDER"] = data[field] + elif field == "llm_model": + provider = data.get("llm_provider", current_settings.get("llm_provider", os.getenv("LLM", "anthropic"))) + env_key = MODEL_ENV_MAP.get(provider) + if env_key: + os.environ[env_key] = data[field] + + # Process API keys (encrypt before storing) + for setting_key, env_key in API_KEY_ENV_MAP.items(): + if setting_key in data and data[setting_key] is not None: + current_api_keys[setting_key] = _encrypt(data[setting_key]) + os.environ[env_key] = data[setting_key] + changed.append(setting_key) + + # Persist + if any(f in changed for f in ("llm_provider", "llm_model", "image_provider")): + await _set_kv(session, SETTINGS_KEY, current_settings) + + if any(f in changed for f in API_KEY_ENV_MAP): + await _set_kv(session, API_KEYS_KEY, current_api_keys) + + return changed diff --git a/backend/utils/process_slides.py b/backend/utils/process_slides.py index b9ddb1d..cb155ce 100644 --- a/backend/utils/process_slides.py +++ b/backend/utils/process_slides.py @@ -47,6 +47,8 @@ async def process_slide_and_fetch_assets( image_dict["__image_url__"] = result.path else: image_dict["__image_url__"] = result + if result == "/static/images/placeholder.jpg": + image_dict["__image_error__"] = "Image generation failed" set_dict_at_path(slide.content, image_path, image_dict) for icon_path in icon_paths: @@ -161,6 +163,8 @@ async def process_old_and_new_slides_and_fetch_assets( else: image_url = fetched_image new_image_dicts[i]["__image_url__"] = image_url + if image_url == "/static/images/placeholder.jpg": + new_image_dicts[i]["__image_error__"] = "Image generation failed" for i, new_icon in enumerate(new_icons): if new_icons_fetch_status[i]: diff --git a/backend/workers/presentation_worker.py b/backend/workers/presentation_worker.py index ddac0b6..6fdf669 100644 --- a/backend/workers/presentation_worker.py +++ b/backend/workers/presentation_worker.py @@ -61,8 +61,8 @@ async def generate_presentation_task(ctx: dict, job_id: str) -> None: tone = presentation.tone or "professional" verbosity = presentation.verbosity or "standard" instructions = presentation.instructions - template = "default" - include_title_slide = True + template = getattr(presentation, "template_name", None) or "general" + include_title_slide = getattr(presentation, "include_title_slide", True) # --- Step 1: Brand context --- brand_context = "" diff --git a/frontend/app/(presentation-generator)/components/EditableLayoutWrapper.tsx b/frontend/app/(presentation-generator)/components/EditableLayoutWrapper.tsx index 5065d91..6c8e550 100644 --- a/frontend/app/(presentation-generator)/components/EditableLayoutWrapper.tsx +++ b/frontend/app/(presentation-generator)/components/EditableLayoutWrapper.tsx @@ -3,6 +3,7 @@ import React, { ReactNode, useRef, useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import { updateSlideImage, updateSlideIcon, updateImageProperties } from '@/store/slices/presentationGeneration'; +import { AlertTriangle } from 'lucide-react'; import ImageEditor from './ImageEditor'; import IconsEditor from './IconsEditor'; @@ -35,6 +36,7 @@ const EditableLayoutWrapper: React.FC = ({ const containerRef = useRef(null); const [editableElements, setEditableElements] = useState([]); const [activeEditor, setActiveEditor] = useState(null); + const [imageErrors, setImageErrors] = useState([]); /** * Recursively searches for ALL image/icon data paths in the slide data structure @@ -315,6 +317,34 @@ const EditableLayoutWrapper: React.FC = ({ }); + // Detect images with errors (placeholder images or __image_error__ in data) + const errorMessages: string[] = []; + const allImgs = containerRef.current?.querySelectorAll('img') || []; + allImgs.forEach((img) => { + const src = (img as HTMLImageElement).src || ''; + if (src.includes('placeholder.jpg') || src.includes('placeholder.png')) { + errorMessages.push('Image generation failed — using placeholder'); + } + }); + // Also check slideData for __image_error__ keys + const checkForErrors = (data: any, path: string = ''): void => { + if (!data || typeof data !== 'object') return; + if (data.__image_error__) { + if (!errorMessages.includes(data.__image_error__)) { + errorMessages.push(data.__image_error__); + } + } + for (const [key, value] of Object.entries(data)) { + if (Array.isArray(value)) { + value.forEach((item, i) => checkForErrors(item, `${path}.${key}[${i}]`)); + } else if (value && typeof value === 'object') { + checkForErrors(value, `${path}.${key}`); + } + } + }; + checkForErrors(slideData); + setImageErrors(errorMessages); + setEditableElements(prev => [...prev, ...newEditableElements]); }; @@ -436,9 +466,19 @@ const EditableLayoutWrapper: React.FC = ({ }; return ( -
+
{children} + {/* Image error warning */} + {imageErrors.length > 0 && ( +
+
+ + {imageErrors.length} image{imageErrors.length > 1 ? 's' : ''} failed +
+
+ )} + {/* Render ImageEditor when an image is being edited */} {activeEditor && activeEditor.type === 'image' && ( { const canChangeKeys = useSelector((state: RootState) => state.userConfig.can_change_keys); const user = useSelector((state: RootState) => state.auth.user); + const dispatch = useDispatch(); + const router = useRouter(); const pathname = usePathname(); const isAdmin = user?.role === 'super_admin' || user?.role === 'client_admin'; + const handleLogout = async () => { + await dispatch(logoutUser()); + router.push('/login'); + }; + return (
@@ -53,6 +61,17 @@ const HeaderNav = () => { )} + +
); }; diff --git a/frontend/app/(presentation-generator)/custom-template/components/LoadingSpinner.tsx b/frontend/app/(presentation-generator)/custom-template/components/LoadingSpinner.tsx index e379f73..85cfde9 100644 --- a/frontend/app/(presentation-generator)/custom-template/components/LoadingSpinner.tsx +++ b/frontend/app/(presentation-generator)/custom-template/components/LoadingSpinner.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { Loader2 } from "lucide-react"; import Header from "@/app/(presentation-generator)/dashboard/components/Header"; +import { HamsterLoader } from "@/components/ui/hamster-loader"; interface LoadingSpinnerProps { message: string; @@ -12,7 +12,7 @@ export const LoadingSpinner: React.FC = ({ message }) => {
- +

{message}

diff --git a/frontend/app/(presentation-generator)/generate/progress/page.tsx b/frontend/app/(presentation-generator)/generate/progress/page.tsx index 915ae34..0bc1d4f 100644 --- a/frontend/app/(presentation-generator)/generate/progress/page.tsx +++ b/frontend/app/(presentation-generator)/generate/progress/page.tsx @@ -12,13 +12,13 @@ import { import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { - Loader2, CheckCircle2, XCircle, RotateCcw, ChevronLeft, StopCircle, } from "lucide-react"; +import { HamsterLoader } from "@/components/ui/hamster-loader"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import Wrapper from "@/components/Wrapper"; @@ -170,7 +170,7 @@ export default function WizardProgressPage() { ) : status === "failed" ? ( ) : ( - + )}
diff --git a/frontend/app/(presentation-generator)/presentation/components/LoadingState.tsx b/frontend/app/(presentation-generator)/presentation/components/LoadingState.tsx index 78a7d14..1527bb6 100644 --- a/frontend/app/(presentation-generator)/presentation/components/LoadingState.tsx +++ b/frontend/app/(presentation-generator)/presentation/components/LoadingState.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { HamsterLoader } from '@/components/ui/hamster-loader'; const LoadingState = () => { @@ -23,8 +24,8 @@ const LoadingState = () => {
-
- +
+

Creating Your Presentation

diff --git a/frontend/app/(presentation-generator)/services/api/dashboard.ts b/frontend/app/(presentation-generator)/services/api/dashboard.ts index 5082600..33ee886 100644 --- a/frontend/app/(presentation-generator)/services/api/dashboard.ts +++ b/frontend/app/(presentation-generator)/services/api/dashboard.ts @@ -24,12 +24,14 @@ export interface PresentationResponse { export class DashboardApi { - static async getPresentations(): Promise { + static async getPresentations(clientId?: string): Promise { try { + const params = clientId ? `?client_id=${clientId}` : ''; const response = await fetch( - `/api/v1/ppt/presentation/all`, + `/api/v1/ppt/presentation/all${params}`, { method: "GET", + headers: getHeader(), } ); diff --git a/frontend/app/(presentation-generator)/template-preview/components/LoadingStates.tsx b/frontend/app/(presentation-generator)/template-preview/components/LoadingStates.tsx index 21b849b..5482cef 100644 --- a/frontend/app/(presentation-generator)/template-preview/components/LoadingStates.tsx +++ b/frontend/app/(presentation-generator)/template-preview/components/LoadingStates.tsx @@ -1,7 +1,8 @@ "use client"; import React from "react"; import { Card, CardContent } from "@/components/ui/card"; -import { Loader2, AlertCircle, FileX } from "lucide-react"; +import { AlertCircle, FileX } from "lucide-react"; +import { HamsterLoader } from "@/components/ui/hamster-loader"; interface LoadingStatesProps { type: "loading" | "error" | "empty"; @@ -14,11 +15,8 @@ const LoadingStates: React.FC = ({ type, message }) => {
-
-
- -
-
+
+
diff --git a/frontend/app/admin/clients/[id]/master-decks/page.tsx b/frontend/app/admin/clients/[id]/master-decks/page.tsx index 03c5179..166873e 100644 --- a/frontend/app/admin/clients/[id]/master-decks/page.tsx +++ b/frontend/app/admin/clients/[id]/master-decks/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, useCallback, useRef } from 'react'; +import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { useParams } from 'next/navigation'; import { useDispatch, useSelector } from 'react-redux'; import { AppDispatch, RootState } from '@/store/store'; @@ -10,6 +10,8 @@ import { deleteMasterDeck, reparseMasterDeck, updateMasterDeckLayout, + deleteMasterDeckLayout, + bulkDeleteMasterDeckLayouts, MasterDeck, } from '@/store/slices/adminSlice'; import Link from 'next/link'; @@ -23,6 +25,13 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { ArrowLeft, Upload, @@ -36,6 +45,12 @@ import { Clock, Loader2, XCircle, + Search, + CheckSquare, + Square, + X, + Code, + AlertTriangle, } from 'lucide-react'; import { toast } from 'sonner'; @@ -106,9 +121,9 @@ export default function MasterDecksPage() { } }; - const handleReparse = async (deckId: string) => { + const handleReparse = async (deckId: string, parseMode?: string) => { try { - await dispatch(reparseMasterDeck(deckId)).unwrap(); + await dispatch(reparseMasterDeck({ deckId, parseMode })).unwrap(); toast.success('Reparse triggered'); dispatch(fetchMasterDecks(clientId)); } catch (err) { @@ -147,6 +162,27 @@ export default function MasterDecksPage() { } }; + const handleDeleteLayout = async (deckId: string, layoutIndex: number) => { + try { + await dispatch(deleteMasterDeckLayout({ deckId, layoutIndex })).unwrap(); + toast.success('Layout deleted'); + dispatch(fetchMasterDecks(clientId)); + } catch { + toast.error('Failed to delete layout'); + } + }; + + const handleBulkDeleteLayouts = async (deckId: string, indices: number[]) => { + if (indices.length === 0) return; + try { + await dispatch(bulkDeleteMasterDeckLayouts({ deckId, indices })).unwrap(); + toast.success(`${indices.length} layout(s) deleted`); + dispatch(fetchMasterDecks(clientId)); + } catch { + toast.error('Failed to delete layouts'); + } + }; + if (loading) { return (
@@ -157,7 +193,7 @@ export default function MasterDecksPage() { } return ( -
+
@@ -197,9 +233,11 @@ export default function MasterDecksPage() { deck={deck} expanded={expandedDeck === deck.id} onToggle={() => setExpandedDeck(expandedDeck === deck.id ? null : deck.id)} - onReparse={() => handleReparse(deck.id)} + onReparse={(parseMode?: string) => handleReparse(deck.id, parseMode)} onDelete={() => handleDelete(deck.id)} onEditLayout={(idx, layout) => setEditLayout({ deckId: deck.id, layoutIndex: idx, layout: { ...layout } })} + onDeleteLayout={(idx) => handleDeleteLayout(deck.id, idx)} + onBulkDeleteLayouts={(indices) => handleBulkDeleteLayouts(deck.id, indices)} /> ))}
@@ -265,15 +303,97 @@ function DeckCard({ onReparse, onDelete, onEditLayout, + onDeleteLayout, + onBulkDeleteLayouts, }: { deck: MasterDeck; expanded: boolean; onToggle: () => void; - onReparse: () => void; + onReparse: (parseMode?: string) => void; onDelete: () => void; onEditLayout: (idx: number, layout: Record) => void; + onDeleteLayout: (idx: number) => void; + onBulkDeleteLayouts: (indices: number[]) => void; }) { const layouts = (deck.layouts || []) as Array>; + const [searchQuery, setSearchQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [codeFilter, setCodeFilter] = useState<'all' | 'has_code' | 'no_code'>('all'); + const [selectedIndices, setSelectedIndices] = useState>(new Set()); + const [selectMode, setSelectMode] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(null); + const [confirmBulkDelete, setConfirmBulkDelete] = useState(false); + + // Extract unique layout types + const layoutTypes = useMemo(() => { + const types = new Set(); + layouts.forEach((l) => { + const t = (l.layout_type as string) || 'custom'; + types.add(t); + }); + return Array.from(types).sort(); + }, [layouts]); + + // Filter layouts + const filteredLayouts = useMemo(() => { + return layouts.map((layout, idx) => ({ layout, originalIndex: idx })).filter(({ layout }) => { + const name = ((layout.layout_name as string) || '').toLowerCase(); + const type = ((layout.layout_type as string) || 'custom').toLowerCase(); + const hasCode = !!(layout.react_code as string); + + if (searchQuery && !name.includes(searchQuery.toLowerCase())) return false; + if (typeFilter !== 'all' && type !== typeFilter.toLowerCase()) return false; + if (codeFilter === 'has_code' && !hasCode) return false; + if (codeFilter === 'no_code' && hasCode) return false; + return true; + }); + }, [layouts, searchQuery, typeFilter, codeFilter]); + + const toggleSelect = (idx: number) => { + const next = new Set(selectedIndices); + if (next.has(idx)) next.delete(idx); + else next.add(idx); + setSelectedIndices(next); + }; + + const selectAll = () => { + const allVisible = new Set(filteredLayouts.map((f) => f.originalIndex)); + setSelectedIndices(allVisible); + }; + + const deselectAll = () => { + setSelectedIndices(new Set()); + }; + + const exitSelectMode = () => { + setSelectMode(false); + setSelectedIndices(new Set()); + }; + + const handleBulkDelete = () => { + if (selectedIndices.size === 0) return; + setConfirmBulkDelete(true); + }; + + const confirmAndBulkDelete = () => { + const indices = Array.from(selectedIndices).sort((a, b) => b - a); + onBulkDeleteLayouts(indices); + exitSelectMode(); + setConfirmBulkDelete(false); + }; + + const handleSingleDelete = (idx: number) => { + setConfirmDelete(idx); + }; + + const confirmAndDelete = () => { + if (confirmDelete !== null) { + onDeleteLayout(confirmDelete); + setConfirmDelete(null); + } + }; + + const isFiltering = searchQuery || typeFilter !== 'all' || codeFilter !== 'all'; return (
@@ -288,20 +408,27 @@ function DeckCard({

{deck.created_at ? new Date(deck.created_at).toLocaleDateString() : ''} {layouts.length > 0 && ` · ${layouts.length} layouts`} + {` · mode: ${deck.parse_mode || 'slides'}`}

- + + + + + Reparse (slides mode) + Reparse (layouts mode) + + @@ -334,43 +461,216 @@ function DeckCard({ )} {layouts.length > 0 && ( -
- {layouts.map((layout, idx) => { - const screenshotPath = layout.screenshot_path as string | undefined; - const screenshotFilename = screenshotPath ? screenshotPath.split('/').pop() : null; - return ( - + + {selectedIndices.size > 0 && ( + + )} + + + ) : ( + + )} +
+
+ + {/* Filter stats */} +
+ {isFiltering + ? `${filteredLayouts.length} of ${layouts.length} layouts` + : `${layouts.length} layouts` + } + {selectedIndices.size > 0 && ` · ${selectedIndices.size} selected`} +
+ + {/* Layout grid */} + {filteredLayouts.length === 0 ? ( +

No layouts match your filters.

+ ) : ( +
+ {filteredLayouts.map(({ layout, originalIndex }) => { + const screenshotPath = layout.screenshot_path as string | undefined; + const screenshotFilename = screenshotPath ? screenshotPath.split('/').pop() : null; + const hasCode = !!(layout.react_code as string); + const isSelected = selectedIndices.has(originalIndex); + + return ( +
+ {/* Select checkbox */} + {selectMode && ( + + )} + + {/* Delete button */} + {!selectMode && ( + + )} + + {/* Clickable area for editing */} +
- )} -
-

- {(layout.layout_name as string) || `Layout ${idx + 1}`} -

- - {(layout.layout_type as string) || 'custom'} - -
- - ); - })} -
+ ); + })} +
+ )} + )}
)} + + {/* Single delete confirmation */} + !open && setConfirmDelete(null)}> + + + Delete Layout + +

+ Are you sure you want to delete this layout? This action cannot be undone. +

+
+ + +
+
+
+ + {/* Bulk delete confirmation */} + + + + Delete {selectedIndices.size} Layouts + +

+ Are you sure you want to delete {selectedIndices.size} selected layout(s)? This action cannot be undone. +

+
+ + +
+
+
); } diff --git a/frontend/app/admin/components/AdminSidebar.tsx b/frontend/app/admin/components/AdminSidebar.tsx index 34360b3..a15bf2b 100644 --- a/frontend/app/admin/components/AdminSidebar.tsx +++ b/frontend/app/admin/components/AdminSidebar.tsx @@ -1,9 +1,10 @@ 'use client'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { useSelector } from 'react-redux'; -import { RootState } from '@/store/store'; +import { usePathname, useRouter } from 'next/navigation'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState, AppDispatch } from '@/store/store'; +import { logoutUser } from '@/store/slices/authSlice'; import { Separator } from '@/components/ui/separator'; import { Users, @@ -13,6 +14,7 @@ import { BarChart3, Settings, ArrowLeft, + LogOut, } from 'lucide-react'; interface NavItem { @@ -33,9 +35,16 @@ const NAV_ITEMS: NavItem[] = [ export default function AdminSidebar() { const pathname = usePathname(); + const router = useRouter(); + const dispatch = useDispatch(); const user = useSelector((state: RootState) => state.auth.user); const role = user?.role || 'user'; + const handleLogout = async () => { + await dispatch(logoutUser()); + router.push('/login'); + }; + const visibleItems = NAV_ITEMS.filter((item) => item.roles.includes(role)); return ( @@ -69,9 +78,16 @@ export default function AdminSidebar() { })}
-
+
Signed in as {user?.email}
+
); diff --git a/frontend/app/admin/settings/page.tsx b/frontend/app/admin/settings/page.tsx index f49b848..19f908d 100644 --- a/frontend/app/admin/settings/page.tsx +++ b/frontend/app/admin/settings/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { Settings, Loader2, @@ -8,6 +8,9 @@ import { Cpu, Image as ImageIcon, Key, + Zap, + CheckCircle2, + XCircle, } from 'lucide-react'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -40,6 +43,8 @@ const PROVIDER_LABELS: Record = { google: 'Google (Gemini)', ollama: 'Ollama (Local)', custom: 'Custom', + nanobanana_pro: 'NanoBanana Pro (Gemini 3)', + gemini_flash: 'Gemini Flash', 'dall-e-3': 'DALL-E 3', 'gpt-image-1.5': 'GPT Image 1.5', pexels: 'Pexels (Stock)', @@ -47,6 +52,12 @@ const PROVIDER_LABELS: Record = { comfyui: 'ComfyUI', }; +interface ConnectionTestResult { + ok: boolean; + error?: string; + latency_ms?: number; +} + export default function SettingsPage() { const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); @@ -61,10 +72,43 @@ export default function SettingsPage() { const [openaiKey, setOpenaiKey] = useState(''); const [googleKey, setGoogleKey] = useState(''); + // Model listing + const [availableModels, setAvailableModels] = useState([]); + const [loadingModels, setLoadingModels] = useState(false); + + // Connection tests + const [testResults, setTestResults] = useState>({}); + const [testingProvider, setTestingProvider] = useState(null); + useEffect(() => { loadSettings(); }, []); + const loadModels = useCallback(async (provider: string) => { + setLoadingModels(true); + setAvailableModels([]); + try { + const res = await fetch(`/api/v1/admin/settings/models?provider=${provider}`, { + headers: getHeader(), + }); + if (res.ok) { + const data = await res.json(); + setAvailableModels(data.models || []); + } + } catch { + // Silently fail — user can still type manually + } finally { + setLoadingModels(false); + } + }, []); + + // Load models when provider changes + useEffect(() => { + if (llmProvider) { + loadModels(llmProvider); + } + }, [llmProvider, loadModels]); + const loadSettings = async () => { setLoading(true); setError(null); @@ -125,6 +169,38 @@ export default function SettingsPage() { } }; + const testConnection = async (provider: string) => { + setTestingProvider(provider); + // Use the currently-typed key if available, otherwise test with stored key + const keyMap: Record = { + anthropic: anthropicKey, + openai: openaiKey, + google: googleKey, + }; + try { + const body: Record = { provider }; + if (keyMap[provider]) body.api_key = keyMap[provider]; + + const res = await fetch('/api/v1/admin/settings/test-connection', { + method: 'POST', + headers: { ...getHeader(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const result: ConnectionTestResult = await res.json(); + setTestResults((prev) => ({ ...prev, [provider]: result })); + if (result.ok) { + toast.success(`${PROVIDER_LABELS[provider] || provider}: Connected (${result.latency_ms}ms)`); + } else { + toast.error(`${PROVIDER_LABELS[provider] || provider}: ${result.error}`); + } + } catch { + setTestResults((prev) => ({ ...prev, [provider]: { ok: false, error: 'Request failed' } })); + toast.error('Connection test failed'); + } finally { + setTestingProvider(null); + } + }; + if (loading) { return (
@@ -158,8 +234,8 @@ export default function SettingsPage() {

- Runtime configuration. Changes apply immediately but don't persist across container restarts. - For permanent changes, update environment variables in .env / docker-compose.yml. + Settings are persisted to the database and survive container restarts. + Environment variables in .env / docker-compose.yml are used as defaults.

{/* LLM Configuration */} @@ -188,11 +264,30 @@ export default function SettingsPage() {
- setLlmModel(e.target.value)} - placeholder="e.g. claude-sonnet-4-6" - /> + {availableModels.length > 0 ? ( + + ) : ( + setLlmModel(e.target.value)} + placeholder={loadingModels ? 'Loading models...' : 'e.g. claude-sonnet-4-6'} + /> + )}
@@ -232,52 +327,109 @@ export default function SettingsPage() {

-
- - setAnthropicKey(e.target.value)} - placeholder={settings?.anthropic_api_key_set ? '••••••••••••' : 'sk-ant-...'} - /> -
- -
- - setOpenaiKey(e.target.value)} - placeholder={settings?.openai_api_key_set ? '••••••••••••' : 'sk-...'} - /> -
- -
- - setGoogleKey(e.target.value)} - placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'} - /> -
+ testConnection('anthropic')} + /> + testConnection('openai')} + /> + testConnection('google')} + />
); } + +function ApiKeyField({ + label, + provider, + isSet, + value, + onChange, + placeholder, + testResult, + isTesting, + onTest, +}: { + label: string; + provider: string; + isSet: boolean; + value: string; + onChange: (v: string) => void; + placeholder: string; + testResult?: ConnectionTestResult; + isTesting: boolean; + onTest: () => void; +}) { + return ( +
+ +
+ onChange(e.target.value)} + placeholder={placeholder} + className="flex-1" + /> + +
+
+ ); +} diff --git a/frontend/app/admin/storage/page.tsx b/frontend/app/admin/storage/page.tsx index 1858fe8..2acfcdf 100644 --- a/frontend/app/admin/storage/page.tsx +++ b/frontend/app/admin/storage/page.tsx @@ -10,6 +10,11 @@ import { Trash2, Loader2, FolderOpen, + CheckSquare, + Square, + Layers, + AlertTriangle, + RefreshCw, } from 'lucide-react'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -19,13 +24,25 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { getHeader } from '@/app/(presentation-generator)/services/api/header'; import { toast } from 'sonner'; +import { HamsterLoader } from '@/components/ui/hamster-loader'; interface StorageSummary { total_presentations: number; total_files: number; total_size_bytes: number; + total_deleted: number; + total_master_decks: number; + master_deck_files: number; + master_deck_size_bytes: number; } interface StoragePresentation { @@ -38,6 +55,11 @@ interface StoragePresentation { has_export: boolean; } +interface ClientOption { + id: string; + name: string; +} + function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; @@ -54,18 +76,36 @@ const STATUS_COLORS: Record = { export default function StoragePage() { const user = useSelector((state: RootState) => state.auth.user); - const clientId = user?.clientId || undefined; + const isSuperAdmin = user?.role === 'super_admin'; + const defaultClientId = user?.clientId || undefined; + const [selectedClientId, setSelectedClientId] = useState( + isSuperAdmin ? undefined : defaultClientId + ); + const [clients, setClients] = useState([]); const [summary, setSummary] = useState(null); const [presentations, setPresentations] = useState([]); const [loading, setLoading] = useState(true); const [deleteTarget, setDeleteTarget] = useState(null); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + const [purging, setPurging] = useState(false); + + // Load client list for super_admin + useEffect(() => { + if (isSuperAdmin) { + fetch('/api/v1/admin/clients', { headers: getHeader() }) + .then((r) => (r.ok ? r.json() : [])) + .then((data) => setClients(data)) + .catch(() => {}); + } + }, [isSuperAdmin]); const load = useCallback(async () => { - if (!clientId) return; setLoading(true); + setSelectedIds(new Set()); try { - const params = `?client_id=${clientId}`; + const params = selectedClientId ? `?client_id=${selectedClientId}` : ''; const headers = getHeader(); const [summaryRes, presRes] = await Promise.all([ fetch(`/api/v1/admin/storage/summary${params}`, { headers }), @@ -78,7 +118,7 @@ export default function StoragePage() { } finally { setLoading(false); } - }, [clientId]); + }, [selectedClientId]); useEffect(() => { load(); @@ -107,21 +147,110 @@ export default function StoragePage() { } }; + const handleBulkDelete = async () => { + try { + const res = await fetch('/api/v1/admin/storage/presentations/bulk-delete', { + method: 'POST', + headers: { ...getHeader(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: Array.from(selectedIds) }), + }); + if (res.ok) { + const data = await res.json(); + toast.success(`Deleted ${data.deleted_count} presentations`); + setBulkDeleteOpen(false); + load(); + } else { + toast.error('Bulk delete failed'); + } + } catch { + toast.error('Bulk delete failed'); + } + }; + + const handlePurge = async () => { + setPurging(true); + try { + const params = selectedClientId ? `?client_id=${selectedClientId}` : ''; + const res = await fetch(`/api/v1/admin/storage/purge${params}`, { + method: 'POST', + headers: getHeader(), + }); + if (res.ok) { + const data = await res.json(); + toast.success( + `Purged ${data.purged_files} files (${formatBytes(data.purged_bytes)})` + ); + load(); + } else { + const err = await res.json().catch(() => ({})); + toast.error(err.detail || 'Purge failed'); + } + } catch { + toast.error('Purge failed'); + } finally { + setPurging(false); + } + }; + + const toggleSelect = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleSelectAll = () => { + if (selectedIds.size === presentations.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(presentations.map((p) => p.id))); + } + }; + if (loading) { return (
- +
); } return (
-

Storage

+
+

Storage

+
+ {/* Client selector for super_admin */} + {isSuperAdmin && clients.length > 0 && ( + + )} + +
+
{/* Summary Cards */} {summary && ( -
+
@@ -144,11 +273,29 @@ export default function StoragePage() {
+ +
+
+

Master Decks

+

+ {summary.total_master_decks} + + ({summary.master_deck_files} files) + +

+
+
+ +
+
+

Total Size

-

{formatBytes(summary.total_size_bytes)}

+

+ {formatBytes(summary.total_size_bytes + (summary.master_deck_size_bytes || 0))} +

@@ -158,12 +305,65 @@ export default function StoragePage() {
)} + {/* Soft-deleted notice + purge */} + {summary && summary.total_deleted > 0 && isSuperAdmin && ( +
+
+ + {summary.total_deleted} soft-deleted presentation(s) with files still on disk. +
+ +
+ )} + + {/* Bulk actions toolbar */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size} selected + + + +
+ )} + {/* Presentations Table */}
+ @@ -175,13 +375,25 @@ export default function StoragePage() { {presentations.length === 0 ? ( - ) : ( presentations.map((p) => ( - + + @@ -247,6 +459,27 @@ export default function StoragePage() { + + {/* Bulk Delete Confirmation */} + !open && setBulkDeleteOpen(false)}> + + + Delete {selectedIds.size} Presentations + +

+ Are you sure you want to delete {selectedIds.size} selected presentations? + This action can be undone by an admin. +

+
+ + +
+
+
); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 612ac0d..6f37b1a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -522,4 +522,222 @@ thead { } .token.entity { cursor: help; +} + +/* Hamster wheel loading animation — from Uiverse.io by Nawsome */ +.wheel-and-hamster { + --dur: 1s; + position: relative; + width: 12em; + height: 12em; + font-size: 14px; +} + +.wheel, +.hamster, +.hamster div, +.spoke { + position: absolute; +} + +.wheel, +.spoke { + border-radius: 50%; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.wheel { + background: radial-gradient(100% 100% at center,hsla(0,0%,60%,0) 47.8%,hsl(0,0%,60%) 48%); + z-index: 2; +} + +.hamster { + animation: hamster var(--dur) ease-in-out infinite; + top: 50%; + left: calc(50% - 3.5em); + width: 7em; + height: 3.75em; + transform: rotate(4deg) translate(-0.8em,1.85em); + transform-origin: 50% 0; + z-index: 1; +} + +.hamster__head { + animation: hamsterHead var(--dur) ease-in-out infinite; + background: hsl(30,90%,55%); + border-radius: 70% 30% 0 100% / 40% 25% 25% 60%; + box-shadow: 0 -0.25em 0 hsl(30,90%,80%) inset, + 0.75em -1.55em 0 hsl(30,90%,90%) inset; + top: 0; + left: -2em; + width: 2.75em; + height: 2.5em; + transform-origin: 100% 50%; +} + +.hamster__ear { + animation: hamsterEar var(--dur) ease-in-out infinite; + background: hsl(0,90%,85%); + border-radius: 50%; + box-shadow: -0.25em 0 hsl(30,90%,55%) inset; + top: -0.25em; + right: -0.25em; + width: 0.75em; + height: 0.75em; + transform-origin: 50% 75%; +} + +.hamster__eye { + animation: hamsterEye var(--dur) linear infinite; + background-color: hsl(0,0%,0%); + border-radius: 50%; + top: 0.375em; + left: 1.25em; + width: 0.5em; + height: 0.5em; +} + +.hamster__nose { + background: hsl(0,90%,75%); + border-radius: 35% 65% 85% 15% / 70% 50% 50% 30%; + top: 0.75em; + left: 0; + width: 0.2em; + height: 0.25em; +} + +.hamster__body { + animation: hamsterBody var(--dur) ease-in-out infinite; + background: hsl(30,90%,90%); + border-radius: 50% 30% 50% 30% / 15% 60% 40% 40%; + box-shadow: 0.1em 0.75em 0 hsl(30,90%,55%) inset, + 0.15em -0.5em 0 hsl(30,90%,80%) inset; + top: 0.25em; + left: 2em; + width: 4.5em; + height: 3em; + transform-origin: 17% 50%; + transform-style: preserve-3d; +} + +.hamster__limb--fr, +.hamster__limb--fl { + clip-path: polygon(0 0,100% 0,70% 80%,60% 100%,0% 100%,40% 80%); + top: 2em; + left: 0.5em; + width: 1em; + height: 1.5em; + transform-origin: 50% 0; +} + +.hamster__limb--fr { + animation: hamsterFRLimb var(--dur) linear infinite; + background: linear-gradient(hsl(30,90%,80%) 80%,hsl(0,90%,75%) 80%); + transform: rotate(15deg) translateZ(-1px); +} + +.hamster__limb--fl { + animation: hamsterFLLimb var(--dur) linear infinite; + background: linear-gradient(hsl(30,90%,90%) 80%,hsl(0,90%,85%) 80%); + transform: rotate(15deg); +} + +.hamster__limb--br, +.hamster__limb--bl { + border-radius: 0.75em 0.75em 0 0; + clip-path: polygon(0 0,100% 0,100% 30%,70% 90%,70% 100%,30% 100%,40% 90%,0% 30%); + top: 1em; + left: 2.8em; + width: 1.5em; + height: 2.5em; + transform-origin: 50% 30%; +} + +.hamster__limb--br { + animation: hamsterBRLimb var(--dur) linear infinite; + background: linear-gradient(hsl(30,90%,80%) 90%,hsl(0,90%,75%) 90%); + transform: rotate(-25deg) translateZ(-1px); +} + +.hamster__limb--bl { + animation: hamsterBLLimb var(--dur) linear infinite; + background: linear-gradient(hsl(30,90%,90%) 90%,hsl(0,90%,85%) 90%); + transform: rotate(-25deg); +} + +.hamster__tail { + animation: hamsterTail var(--dur) linear infinite; + background: hsl(0,90%,85%); + border-radius: 0.25em 50% 50% 0.25em; + box-shadow: 0 -0.2em 0 hsl(0,90%,75%) inset; + top: 1.5em; + right: -0.5em; + width: 1em; + height: 0.5em; + transform: rotate(30deg) translateZ(-1px); + transform-origin: 0.25em 0.25em; +} + +.spoke { + animation: spoke var(--dur) linear infinite; + background: radial-gradient(100% 100% at center,hsl(0,0%,60%) 4.8%,hsla(0,0%,60%,0) 5%), + linear-gradient(hsla(0,0%,55%,0) 46.9%,hsl(0,0%,65%) 47% 52.9%,hsla(0,0%,65%,0) 53%) 50% 50% / 99% 99% no-repeat; +} + +@keyframes hamster { + from, to { transform: rotate(4deg) translate(-0.8em,1.85em); } + 50% { transform: rotate(0) translate(-0.8em,1.85em); } +} + +@keyframes hamsterHead { + from, 25%, 50%, 75%, to { transform: rotate(0); } + 12.5%, 37.5%, 62.5%, 87.5% { transform: rotate(8deg); } +} + +@keyframes hamsterEye { + from, 90%, to { transform: scaleY(1); } + 95% { transform: scaleY(0); } +} + +@keyframes hamsterEar { + from, 25%, 50%, 75%, to { transform: rotate(0); } + 12.5%, 37.5%, 62.5%, 87.5% { transform: rotate(12deg); } +} + +@keyframes hamsterBody { + from, 25%, 50%, 75%, to { transform: rotate(0); } + 12.5%, 37.5%, 62.5%, 87.5% { transform: rotate(-2deg); } +} + +@keyframes hamsterFRLimb { + from, 25%, 50%, 75%, to { transform: rotate(50deg) translateZ(-1px); } + 12.5%, 37.5%, 62.5%, 87.5% { transform: rotate(-30deg) translateZ(-1px); } +} + +@keyframes hamsterFLLimb { + from, 25%, 50%, 75%, to { transform: rotate(-30deg); } + 12.5%, 37.5%, 62.5%, 87.5% { transform: rotate(50deg); } +} + +@keyframes hamsterBRLimb { + from, 25%, 50%, 75%, to { transform: rotate(-60deg) translateZ(-1px); } + 12.5%, 37.5%, 62.5%, 87.5% { transform: rotate(20deg) translateZ(-1px); } +} + +@keyframes hamsterBLLimb { + from, 25%, 50%, 75%, to { transform: rotate(20deg); } + 12.5%, 37.5%, 62.5%, 87.5% { transform: rotate(-60deg); } +} + +@keyframes hamsterTail { + from, 25%, 50%, 75%, to { transform: rotate(30deg) translateZ(-1px); } + 12.5%, 37.5%, 62.5%, 87.5% { transform: rotate(10deg) translateZ(-1px); } +} + +@keyframes spoke { + from { transform: rotate(0); } + to { transform: rotate(-1turn); } } \ No newline at end of file diff --git a/frontend/components/ui/hamster-loader.tsx b/frontend/components/ui/hamster-loader.tsx new file mode 100644 index 0000000..ba1949f --- /dev/null +++ b/frontend/components/ui/hamster-loader.tsx @@ -0,0 +1,44 @@ +/** + * Hamster wheel loading animation. + * From Uiverse.io by Nawsome — converted to React component. + */ +import React from 'react'; + +interface HamsterLoaderProps { + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +const SIZE_MAP = { + sm: '8px', + md: '14px', + lg: '20px', +}; + +export function HamsterLoader({ size = 'md', className = '' }: HamsterLoaderProps) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/frontend/components/ui/loader.tsx b/frontend/components/ui/loader.tsx index 5dc6d07..af07464 100644 --- a/frontend/components/ui/loader.tsx +++ b/frontend/components/ui/loader.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils" +import { HamsterLoader } from "@/components/ui/hamster-loader" interface LoaderProps { text?: string @@ -8,7 +9,7 @@ interface LoaderProps { export const Loader = ({ text, className }: LoaderProps) => { return (
-
+ {text && (

{text}

)} diff --git a/frontend/store/slices/adminSlice.ts b/frontend/store/slices/adminSlice.ts index 8fb2cbf..510d3f1 100644 --- a/frontend/store/slices/adminSlice.ts +++ b/frontend/store/slices/adminSlice.ts @@ -60,6 +60,7 @@ export interface MasterDeck { name: string; description: string | null; thumbnail_path: string | null; + parse_mode: string; parse_status: string; is_active: boolean; layouts: Array> | null; @@ -299,8 +300,9 @@ export const updateMasterDeckLayout = createAsyncThunk( export const reparseMasterDeck = createAsyncThunk( "admin/reparseMasterDeck", - async (deckId: string, { rejectWithValue }) => { - const res = await fetch(`/api/v1/admin/master-decks/${deckId}/reparse`, { method: "POST" }); + async ({ deckId, parseMode }: { deckId: string; parseMode?: string }, { rejectWithValue }) => { + const params = parseMode ? `?parse_mode=${parseMode}` : ""; + const res = await fetch(`/api/v1/admin/master-decks/${deckId}/reparse${params}`, { method: "POST" }); if (!res.ok) { const data = await res.json().catch(() => ({})); return rejectWithValue(data.detail || "Failed to trigger reparse"); @@ -318,6 +320,30 @@ export const deleteMasterDeck = createAsyncThunk( } ); +export const deleteMasterDeckLayout = createAsyncThunk( + "admin/deleteMasterDeckLayout", + async ({ deckId, layoutIndex }: { deckId: string; layoutIndex: number }, { rejectWithValue }) => { + const res = await fetch(`/api/v1/admin/master-decks/${deckId}/layouts/${layoutIndex}`, { + method: "DELETE", + }); + if (!res.ok) return rejectWithValue("Failed to delete layout"); + return { deckId, layoutIndex }; + } +); + +export const bulkDeleteMasterDeckLayouts = createAsyncThunk( + "admin/bulkDeleteMasterDeckLayouts", + async ({ deckId, indices }: { deckId: string; indices: number[] }, { rejectWithValue }) => { + const res = await fetch(`/api/v1/admin/master-decks/${deckId}/layouts/bulk-delete`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ indices }), + }); + if (!res.ok) return rejectWithValue("Failed to delete layouts"); + return await res.json(); + } +); + const adminSlice = createSlice({ name: "admin", initialState, diff --git a/frontend/store/slices/authSlice.ts b/frontend/store/slices/authSlice.ts index 28f2bb8..536975d 100644 --- a/frontend/store/slices/authSlice.ts +++ b/frontend/store/slices/authSlice.ts @@ -56,6 +56,14 @@ export const checkDevMode = createAsyncThunk( } ); +export const logoutUser = createAsyncThunk( + "auth/logoutUser", + async () => { + await fetch("/api/v1/auth/logout", { method: "POST" }); + return true; + } +); + const authSlice = createSlice({ name: "auth", initialState, @@ -82,6 +90,10 @@ const authSlice = createSlice({ }) .addCase(checkDevMode.fulfilled, (state, action) => { state.isDevMode = action.payload; + }) + .addCase(logoutUser.fulfilled, (state) => { + state.user = null; + state.isAuthenticated = false; }); }, });
+ + Title Status Created
+ No presentations found.
+ + {p.title || 'Untitled'}