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:
Vadym Samoilenko 2026-02-27 12:58:52 +00:00
parent d3d1667a79
commit 69a8829750
30 changed files with 2127 additions and 200 deletions

View file

@ -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()

View file

@ -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()

View file

@ -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}"

View file

@ -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,
}

View file

@ -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",

View file

@ -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')

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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(

View file

@ -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),
}

View 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

View file

@ -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]:

View file

@ -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 = ""

View file

@ -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

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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(),
}
);

View file

@ -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">

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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&apos;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>
);
}

View file

@ -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>
);
}

View file

@ -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); }
}

View 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>
);
}

View file

@ -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>
)}

View file

@ -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,

View file

@ -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;
});
},
});