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 <noreply@anthropic.com>
This commit is contained in:
parent
d3d1667a79
commit
69a8829750
30 changed files with 2127 additions and 200 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
174
backend/services/settings_service.py
Normal file
174
backend/services/settings_service.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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<EditableLayoutWrapperProps> = ({
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [editableElements, setEditableElements] = useState<EditableElement[]>([]);
|
||||
const [activeEditor, setActiveEditor] = useState<EditableElement | null>(null);
|
||||
const [imageErrors, setImageErrors] = useState<string[]>([]);
|
||||
|
||||
/**
|
||||
* Recursively searches for ALL image/icon data paths in the slide data structure
|
||||
|
|
@ -315,6 +317,34 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
|
|||
});
|
||||
|
||||
|
||||
// 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<EditableLayoutWrapperProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="editable-layout-wrapper w-full ">
|
||||
<div ref={containerRef} className="editable-layout-wrapper w-full relative">
|
||||
{children}
|
||||
|
||||
{/* Image error warning */}
|
||||
{imageErrors.length > 0 && (
|
||||
<div className="absolute top-2 right-2 z-10" title={imageErrors.join('\n')}>
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-amber-100 border border-amber-300 rounded-md text-amber-700 text-xs shadow-sm">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{imageErrors.length} image{imageErrors.length > 1 ? 's' : ''} failed</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render ImageEditor when an image is being edited */}
|
||||
{activeEditor && activeEditor.type === 'image' && (
|
||||
<ImageEditor
|
||||
|
|
|
|||
|
|
@ -1,18 +1,26 @@
|
|||
"use client";
|
||||
import { LayoutDashboard, Settings, Shield, Upload } from "lucide-react";
|
||||
import { LayoutDashboard, Settings, Shield, LogOut } from "lucide-react";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { RootState, AppDispatch } from "@/store/store";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { logoutUser } from "@/store/slices/authSlice";
|
||||
|
||||
const HeaderNav = () => {
|
||||
|
||||
const canChangeKeys = useSelector((state: RootState) => state.userConfig.can_change_keys);
|
||||
const user = useSelector((state: RootState) => state.auth.user);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
|
|
@ -53,6 +61,17 @@ const HeaderNav = () => {
|
|||
</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="text-sm font-medium font-inter">
|
||||
Sign out
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<LoadingSpinnerProps> = ({ message }) => {
|
|||
<Header />
|
||||
<div className="flex items-center justify-center aspect-video mx-auto px-6">
|
||||
<div className="text-center space-y-2 my-6 bg-white p-6 rounded-lg shadow-md">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-600 mx-auto" />
|
||||
<HamsterLoader size="md" />
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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" ? (
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto" />
|
||||
) : (
|
||||
<Loader2 className="w-16 h-16 text-[#5146E5] mx-auto animate-spin" />
|
||||
<HamsterLoader size="lg" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 mx-auto w-[500px] flex flex-col items-center justify-center p-8">
|
||||
<div className="w-full bg-white rounded-xl p-[2px] ">
|
||||
<div className="bg-white rounded-xl p-6 w-full">
|
||||
<div className="flex items-center justify-center space-x-4 ">
|
||||
|
||||
<div className="flex flex-col items-center justify-center space-y-4 mb-4">
|
||||
<HamsterLoader size="lg" />
|
||||
<h2 className="text-2xl font-semibold text-gray-800">Creating Your Presentation</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-md bg-white/80 backdrop-blur-sm rounded-xl shadow-sm p-6 mb-4">
|
||||
|
|
|
|||
|
|
@ -24,12 +24,14 @@ export interface PresentationResponse {
|
|||
|
||||
export class DashboardApi {
|
||||
|
||||
static async getPresentations(): Promise<PresentationResponse[]> {
|
||||
static async getPresentations(clientId?: string): Promise<PresentationResponse[]> {
|
||||
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(),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<LoadingStatesProps> = ({ type, message }) => {
|
|||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 flex items-center justify-center">
|
||||
<Card className="p-8 text-center shadow-xl border-0 bg-white/80 backdrop-blur-sm">
|
||||
<CardContent className="space-y-6">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 mx-auto mb-4 relative">
|
||||
<Loader2 className="w-16 h-16 text-blue-500 animate-spin" />
|
||||
<div className="absolute inset-0 w-16 h-16 border-4 border-blue-100 rounded-full animate-pulse"></div>
|
||||
</div>
|
||||
<div className="flex justify-center mb-4">
|
||||
<HamsterLoader size="lg" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -157,7 +193,7 @@ export default function MasterDecksPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div className="space-y-6 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/clients/${clientId}`}>
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -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<string, unknown>) => void;
|
||||
onDeleteLayout: (idx: number) => void;
|
||||
onBulkDeleteLayouts: (indices: number[]) => void;
|
||||
}) {
|
||||
const layouts = (deck.layouts || []) as Array<Record<string, unknown>>;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [codeFilter, setCodeFilter] = useState<'all' | 'has_code' | 'no_code'>('all');
|
||||
const [selectedIndices, setSelectedIndices] = useState<Set<number>>(new Set());
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
|
||||
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
|
||||
|
||||
// Extract unique layout types
|
||||
const layoutTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
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 (
|
||||
<div className="bg-white rounded-lg border">
|
||||
|
|
@ -288,20 +408,27 @@ function DeckCard({
|
|||
<p className="text-xs text-gray-500">
|
||||
{deck.created_at ? new Date(deck.created_at).toLocaleDateString() : ''}
|
||||
{layouts.length > 0 && ` · ${layouts.length} layouts`}
|
||||
{` · mode: ${deck.parse_mode || 'slides'}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={deck.parse_status} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onReparse}
|
||||
<Select
|
||||
defaultValue=""
|
||||
onValueChange={(v) => {
|
||||
if (v === 'slides' || v === 'layouts') onReparse(v);
|
||||
}}
|
||||
disabled={deck.parse_status === 'processing'}
|
||||
title="Reparse"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<SelectTrigger className="w-auto h-8 px-2 border-none shadow-none hover:bg-gray-100" title="Reparse">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="slides">Reparse (slides mode)</SelectItem>
|
||||
<SelectItem value="layouts">Reparse (layouts mode)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="sm" onClick={onDelete} title="Remove">
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
|
|
@ -334,43 +461,216 @@ function DeckCard({
|
|||
)}
|
||||
|
||||
{layouts.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{layouts.map((layout, idx) => {
|
||||
const screenshotPath = layout.screenshot_path as string | undefined;
|
||||
const screenshotFilename = screenshotPath ? screenshotPath.split('/').pop() : null;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onEditLayout(idx, layout)}
|
||||
className="text-left bg-gray-50 rounded-lg border p-2 hover:border-[#5146E5] hover:bg-[#5146E5]/5 transition-colors group overflow-hidden"
|
||||
>
|
||||
{screenshotFilename ? (
|
||||
<img
|
||||
src={`/api/v1/admin/master-decks/${deck.id}/screenshot/${screenshotFilename}`}
|
||||
alt={(layout.layout_name as string) || `Layout ${idx + 1}`}
|
||||
className="w-full h-24 object-cover rounded mb-2 bg-white"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-24 flex items-center justify-center bg-gray-100 rounded mb-2">
|
||||
<FileCode className="w-8 h-8 text-gray-300" />
|
||||
<>
|
||||
{/* Toolbar: Search + Filters + Select Mode */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<div className="relative flex-1 min-w-[180px] max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search layouts..."
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-sm">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{layoutTypes.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={codeFilter} onValueChange={(v) => setCodeFilter(v as typeof codeFilter)}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-sm">
|
||||
<SelectValue placeholder="Code" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="has_code">Has code</SelectItem>
|
||||
<SelectItem value="no_code">Missing code</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={selectAll} className="h-8 text-xs">
|
||||
Select all
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={deselectAll} className="h-8 text-xs">
|
||||
Deselect
|
||||
</Button>
|
||||
{selectedIndices.size > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleBulkDelete}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||
Delete {selectedIndices.size}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={exitSelectMode} className="h-8 text-xs">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectMode(true)} className="h-8 text-xs">
|
||||
<CheckSquare className="w-3.5 h-3.5 mr-1" />
|
||||
Select
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter stats */}
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
{isFiltering
|
||||
? `${filteredLayouts.length} of ${layouts.length} layouts`
|
||||
: `${layouts.length} layouts`
|
||||
}
|
||||
{selectedIndices.size > 0 && ` · ${selectedIndices.size} selected`}
|
||||
</div>
|
||||
|
||||
{/* Layout grid */}
|
||||
{filteredLayouts.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 text-center py-6">No layouts match your filters.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{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 (
|
||||
<div
|
||||
key={originalIndex}
|
||||
className={`relative text-left bg-gray-50 rounded-lg border p-2 transition-colors group overflow-hidden ${
|
||||
isSelected
|
||||
? 'border-[#5146E5] bg-[#5146E5]/5 ring-1 ring-[#5146E5]'
|
||||
: 'hover:border-[#5146E5] hover:bg-[#5146E5]/5'
|
||||
}`}
|
||||
>
|
||||
{/* Select checkbox */}
|
||||
{selectMode && (
|
||||
<button
|
||||
onClick={() => toggleSelect(originalIndex)}
|
||||
className="absolute top-2 left-2 z-10 bg-white rounded shadow-sm"
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="w-5 h-5 text-[#5146E5]" />
|
||||
) : (
|
||||
<Square className="w-5 h-5 text-gray-300 hover:text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
{!selectMode && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSingleDelete(originalIndex);
|
||||
}}
|
||||
className="absolute top-2 right-2 z-10 p-1 bg-white rounded shadow-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-50"
|
||||
title="Delete layout"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Clickable area for editing */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectMode) {
|
||||
toggleSelect(originalIndex);
|
||||
} else {
|
||||
onEditLayout(originalIndex, layout);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left"
|
||||
>
|
||||
{screenshotFilename ? (
|
||||
<img
|
||||
src={`/api/v1/admin/master-decks/${deck.id}/screenshot/${screenshotFilename}`}
|
||||
alt={(layout.layout_name as string) || `Layout ${originalIndex + 1}`}
|
||||
className="w-full h-24 object-cover rounded mb-2 bg-white"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-24 flex items-center justify-center bg-gray-100 rounded mb-2">
|
||||
<FileCode className="w-8 h-8 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
<div className="px-1">
|
||||
<p className="text-xs font-medium truncate">
|
||||
{(layout.layout_name as string) || `Layout ${originalIndex + 1}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="text-[10px] text-gray-400 px-1.5 py-0.5 bg-gray-200 rounded">
|
||||
{(layout.layout_type as string) || 'custom'}
|
||||
</span>
|
||||
{hasCode ? (
|
||||
<span className="text-[10px] text-green-600 px-1.5 py-0.5 bg-green-50 rounded flex items-center gap-0.5">
|
||||
<Code className="w-2.5 h-2.5" /> TSX
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-orange-600 px-1.5 py-0.5 bg-orange-50 rounded flex items-center gap-0.5">
|
||||
<AlertTriangle className="w-2.5 h-2.5" /> No code
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-1">
|
||||
<p className="text-xs font-medium truncate">
|
||||
{(layout.layout_name as string) || `Layout ${idx + 1}`}
|
||||
</p>
|
||||
<span className="text-[10px] text-gray-400 px-1.5 py-0.5 bg-gray-200 rounded inline-block mt-1">
|
||||
{(layout.layout_type as string) || 'custom'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single delete confirmation */}
|
||||
<Dialog open={confirmDelete !== null} onOpenChange={(open) => !open && setConfirmDelete(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Layout</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-gray-600">
|
||||
Are you sure you want to delete this layout? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmDelete(null)}>Cancel</Button>
|
||||
<Button variant="destructive" size="sm" onClick={confirmAndDelete}>Delete</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk delete confirmation */}
|
||||
<Dialog open={confirmBulkDelete} onOpenChange={setConfirmBulkDelete}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {selectedIndices.size} Layouts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-gray-600">
|
||||
Are you sure you want to delete {selectedIndices.size} selected layout(s)? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmBulkDelete(false)}>Cancel</Button>
|
||||
<Button variant="destructive" size="sm" onClick={confirmAndBulkDelete}>Delete All</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<AppDispatch>();
|
||||
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() {
|
|||
})}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className="text-xs text-gray-400">
|
||||
<div className="text-xs text-gray-400 mb-2">
|
||||
Signed in as <span className="font-medium text-gray-600">{user?.email}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 text-xs text-gray-500 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
|||
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<string, string> = {
|
|||
comfyui: 'ComfyUI',
|
||||
};
|
||||
|
||||
interface ConnectionTestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
latency_ms?: number;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<SystemSettings | null>(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<string[]>([]);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
|
||||
// Connection tests
|
||||
const [testResults, setTestResults] = useState<Record<string, ConnectionTestResult>>({});
|
||||
const [testingProvider, setTestingProvider] = useState<string | null>(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<string, string> = {
|
||||
anthropic: anthropicKey,
|
||||
openai: openaiKey,
|
||||
google: googleKey,
|
||||
};
|
||||
try {
|
||||
const body: Record<string, string> = { 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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
|
|
@ -158,8 +234,8 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* LLM Configuration */}
|
||||
|
|
@ -188,11 +264,30 @@ export default function SettingsPage() {
|
|||
|
||||
<div className="space-y-1">
|
||||
<Label>Model</Label>
|
||||
<Input
|
||||
value={llmModel}
|
||||
onChange={(e) => setLlmModel(e.target.value)}
|
||||
placeholder="e.g. claude-sonnet-4-6"
|
||||
/>
|
||||
{availableModels.length > 0 ? (
|
||||
<Select value={llmModel} onValueChange={setLlmModel}>
|
||||
<SelectTrigger>
|
||||
{loadingModels ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<SelectValue placeholder="Select model" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={llmModel}
|
||||
onChange={(e) => setLlmModel(e.target.value)}
|
||||
placeholder={loadingModels ? 'Loading models...' : 'e.g. claude-sonnet-4-6'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -232,52 +327,109 @@ export default function SettingsPage() {
|
|||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="flex items-center gap-2">
|
||||
Anthropic API Key
|
||||
{settings?.anthropic_api_key_set && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={anthropicKey}
|
||||
onChange={(e) => setAnthropicKey(e.target.value)}
|
||||
placeholder={settings?.anthropic_api_key_set ? '••••••••••••' : 'sk-ant-...'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="flex items-center gap-2">
|
||||
OpenAI API Key
|
||||
{settings?.openai_api_key_set && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={openaiKey}
|
||||
onChange={(e) => setOpenaiKey(e.target.value)}
|
||||
placeholder={settings?.openai_api_key_set ? '••••••••••••' : 'sk-...'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="flex items-center gap-2">
|
||||
Google API Key
|
||||
{settings?.google_api_key_set && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={googleKey}
|
||||
onChange={(e) => setGoogleKey(e.target.value)}
|
||||
placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'}
|
||||
/>
|
||||
</div>
|
||||
<ApiKeyField
|
||||
label="Anthropic API Key"
|
||||
provider="anthropic"
|
||||
isSet={!!settings?.anthropic_api_key_set}
|
||||
value={anthropicKey}
|
||||
onChange={setAnthropicKey}
|
||||
placeholder={settings?.anthropic_api_key_set ? '••••••••••••' : 'sk-ant-...'}
|
||||
testResult={testResults.anthropic}
|
||||
isTesting={testingProvider === 'anthropic'}
|
||||
onTest={() => testConnection('anthropic')}
|
||||
/>
|
||||
<ApiKeyField
|
||||
label="OpenAI API Key"
|
||||
provider="openai"
|
||||
isSet={!!settings?.openai_api_key_set}
|
||||
value={openaiKey}
|
||||
onChange={setOpenaiKey}
|
||||
placeholder={settings?.openai_api_key_set ? '••••••••••••' : 'sk-...'}
|
||||
testResult={testResults.openai}
|
||||
isTesting={testingProvider === 'openai'}
|
||||
onTest={() => testConnection('openai')}
|
||||
/>
|
||||
<ApiKeyField
|
||||
label="Google API Key"
|
||||
provider="google"
|
||||
isSet={!!settings?.google_api_key_set}
|
||||
value={googleKey}
|
||||
onChange={setGoogleKey}
|
||||
placeholder={settings?.google_api_key_set ? '••••••••••••' : 'AIza...'}
|
||||
testResult={testResults.google}
|
||||
isTesting={testingProvider === 'google'}
|
||||
onTest={() => testConnection('google')}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
<Label className="flex items-center gap-2">
|
||||
{label}
|
||||
{isSet && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full">Set</span>
|
||||
)}
|
||||
{testResult && (
|
||||
testResult.ok ? (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded-full flex items-center gap-0.5">
|
||||
<CheckCircle2 className="w-2.5 h-2.5" /> OK {testResult.latency_ms && `(${testResult.latency_ms}ms)`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-red-100 text-red-700 rounded-full flex items-center gap-0.5">
|
||||
<XCircle className="w-2.5 h-2.5" /> Failed
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className="h-9 px-3 text-xs whitespace-nowrap"
|
||||
title="Test connection"
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
|||
|
||||
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<string | undefined>(
|
||||
isSuperAdmin ? undefined : defaultClientId
|
||||
);
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
const [summary, setSummary] = useState<StorageSummary | null>(null);
|
||||
const [presentations, setPresentations] = useState<StoragePresentation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleteTarget, setDeleteTarget] = useState<StoragePresentation | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||
<HamsterLoader size="md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Storage</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Storage</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Client selector for super_admin */}
|
||||
{isSuperAdmin && clients.length > 0 && (
|
||||
<Select
|
||||
value={selectedClientId || '__all__'}
|
||||
onValueChange={(v) => setSelectedClientId(v === '__all__' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="All clients" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All clients</SelectItem>
|
||||
{clients.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={load}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
|
|
@ -144,11 +273,29 @@ export default function StoragePage() {
|
|||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Master Decks</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{summary.total_master_decks}
|
||||
<span className="text-sm font-normal text-gray-400 ml-1">
|
||||
({summary.master_deck_files} files)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-gray-50 text-purple-600">
|
||||
<Layers className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Total Size</p>
|
||||
<p className="text-2xl font-bold mt-1">{formatBytes(summary.total_size_bytes)}</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{formatBytes(summary.total_size_bytes + (summary.master_deck_size_bytes || 0))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-gray-50 text-green-600">
|
||||
<HardDrive className="w-5 h-5" />
|
||||
|
|
@ -158,12 +305,65 @@ export default function StoragePage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Soft-deleted notice + purge */}
|
||||
{summary && summary.total_deleted > 0 && isSuperAdmin && (
|
||||
<div className="flex items-center justify-between p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm text-amber-700">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>{summary.total_deleted} soft-deleted presentation(s) with files still on disk.</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePurge}
|
||||
disabled={purging}
|
||||
className="text-amber-700 border-amber-300 hover:bg-amber-100"
|
||||
>
|
||||
{purging ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Trash2 className="w-3.5 h-3.5 mr-1" />}
|
||||
Purge Files
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk actions toolbar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-3 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<span className="text-sm text-blue-700 font-medium">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setBulkDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||
Delete Selected
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Presentations Table */}
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-gray-50">
|
||||
<th className="p-3 w-10">
|
||||
<button onClick={toggleSelectAll} className="text-gray-400 hover:text-gray-600">
|
||||
{selectedIds.size === presentations.length && presentations.length > 0 ? (
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
) : (
|
||||
<Square className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="text-left p-3 font-medium text-gray-600">Title</th>
|
||||
<th className="text-left p-3 font-medium text-gray-600">Status</th>
|
||||
<th className="text-left p-3 font-medium text-gray-600">Created</th>
|
||||
|
|
@ -175,13 +375,25 @@ export default function StoragePage() {
|
|||
<tbody>
|
||||
{presentations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-8 text-center text-gray-400">
|
||||
<td colSpan={7} className="p-8 text-center text-gray-400">
|
||||
No presentations found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
presentations.map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<tr
|
||||
key={p.id}
|
||||
className={`border-b last:border-0 hover:bg-gray-50 ${selectedIds.has(p.id) ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<td className="p-3">
|
||||
<button onClick={() => toggleSelect(p.id)} className="text-gray-400 hover:text-gray-600">
|
||||
{selectedIds.has(p.id) ? (
|
||||
<CheckSquare className="w-4 h-4 text-blue-600" />
|
||||
) : (
|
||||
<Square className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span className="font-medium">{p.title || 'Untitled'}</span>
|
||||
</td>
|
||||
|
|
@ -247,6 +459,27 @@ export default function StoragePage() {
|
|||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Delete Confirmation */}
|
||||
<Dialog open={bulkDeleteOpen} onOpenChange={(open) => !open && setBulkDeleteOpen(false)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {selectedIds.size} Presentations</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-gray-500">
|
||||
Are you sure you want to delete {selectedIds.size} selected presentations?
|
||||
This action can be undone by an admin.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="outline" onClick={() => setBulkDeleteOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleBulkDelete}>
|
||||
Delete All
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
44
frontend/components/ui/hamster-loader.tsx
Normal file
44
frontend/components/ui/hamster-loader.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
aria-label="Loading"
|
||||
role="status"
|
||||
className={`wheel-and-hamster ${className}`}
|
||||
style={{ fontSize: SIZE_MAP[size] }}
|
||||
>
|
||||
<div className="wheel" />
|
||||
<div className="hamster">
|
||||
<div className="hamster__head">
|
||||
<div className="hamster__ear" />
|
||||
<div className="hamster__eye" />
|
||||
<div className="hamster__nose" />
|
||||
</div>
|
||||
<div className="hamster__body">
|
||||
<div className="hamster__limb hamster__limb--fr" />
|
||||
<div className="hamster__limb hamster__limb--fl" />
|
||||
<div className="hamster__limb hamster__limb--br" />
|
||||
<div className="hamster__limb hamster__limb--bl" />
|
||||
<div className="hamster__tail" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="spoke" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className={cn("flex flex-col items-center justify-center", className)}>
|
||||
<div className="w-8 h-8 border-4 border-purple-200 border-t-purple-600 rounded-full animate-spin"></div>
|
||||
<HamsterLoader size="md" />
|
||||
{text && (
|
||||
<p className="mt-4 text-white text-base font-inter font-semibold">{text}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> | 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue