Add pagination and API improvements
- Add pagination to effects list with infinite scroll - Refactor home stats into reusable get_user_stats function - Add /api/stats endpoint for CLI/API clients - Add has_more flag to recipes listing - Add JSON API support to storage type page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -310,9 +310,11 @@ async def get_effect_source(
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_effects(
|
async def list_effects(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
ctx: UserContext = Depends(require_auth),
|
ctx: UserContext = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""List all uploaded effects."""
|
"""List uploaded effects with pagination."""
|
||||||
effects_dir = get_effects_dir()
|
effects_dir = get_effects_dir()
|
||||||
effects = []
|
effects = []
|
||||||
|
|
||||||
@@ -341,18 +343,26 @@ async def list_effects(
|
|||||||
# Sort by upload time (newest first)
|
# Sort by upload time (newest first)
|
||||||
effects.sort(key=lambda e: e.get("uploaded_at", ""), reverse=True)
|
effects.sort(key=lambda e: e.get("uploaded_at", ""), reverse=True)
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
total = len(effects)
|
||||||
|
paginated_effects = effects[offset:offset + limit]
|
||||||
|
has_more = offset + limit < total
|
||||||
|
|
||||||
if wants_json(request):
|
if wants_json(request):
|
||||||
return {"effects": effects}
|
return {"effects": paginated_effects, "offset": offset, "limit": limit, "has_more": has_more}
|
||||||
|
|
||||||
from ..dependencies import get_nav_counts
|
from ..dependencies import get_nav_counts
|
||||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||||
|
|
||||||
templates = get_templates(request)
|
templates = get_templates(request)
|
||||||
return render(templates, "effects/list.html", request,
|
return render(templates, "effects/list.html", request,
|
||||||
effects=effects,
|
effects=paginated_effects,
|
||||||
user=ctx,
|
user=ctx,
|
||||||
nav_counts=nav_counts,
|
nav_counts=nav_counts,
|
||||||
active_tab="effects",
|
active_tab="effects",
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
has_more=has_more,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,68 @@ from ..dependencies import get_templates, get_current_user
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_stats(actor_id: str) -> dict:
|
||||||
|
"""Get stats for a user."""
|
||||||
|
import database
|
||||||
|
from ..services.recipe_service import RecipeService
|
||||||
|
from ..services.run_service import RunService
|
||||||
|
from ..dependencies import get_redis_client, get_cache_manager
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats["media"] = await database.count_user_items(actor_id)
|
||||||
|
except Exception:
|
||||||
|
stats["media"] = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
|
||||||
|
recipes = await recipe_service.list_recipes(actor_id)
|
||||||
|
stats["recipes"] = len(recipes)
|
||||||
|
except Exception:
|
||||||
|
stats["recipes"] = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
run_service = RunService(database, get_redis_client(), get_cache_manager())
|
||||||
|
runs = await run_service.list_runs(actor_id)
|
||||||
|
stats["runs"] = len(runs)
|
||||||
|
except Exception:
|
||||||
|
stats["runs"] = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage_providers = await database.get_user_storage_providers(actor_id)
|
||||||
|
stats["storage"] = len(storage_providers) if storage_providers else 0
|
||||||
|
except Exception:
|
||||||
|
stats["storage"] = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
effects_dir = Path(get_cache_manager().cache_dir) / "_effects"
|
||||||
|
if effects_dir.exists():
|
||||||
|
stats["effects"] = len([d for d in effects_dir.iterdir() if d.is_dir()])
|
||||||
|
else:
|
||||||
|
stats["effects"] = 0
|
||||||
|
except Exception:
|
||||||
|
stats["effects"] = 0
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/stats")
|
||||||
|
async def api_stats(request: Request):
|
||||||
|
"""Get user stats as JSON for CLI and API clients."""
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
|
|
||||||
|
stats = await get_user_stats(user.actor_id)
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def home(request: Request):
|
async def home(request: Request):
|
||||||
"""
|
"""
|
||||||
Home page - show README and stats.
|
Home page - show README and stats.
|
||||||
"""
|
"""
|
||||||
import database
|
|
||||||
user = await get_current_user(request)
|
user = await get_current_user(request)
|
||||||
|
|
||||||
# Load README
|
# Load README
|
||||||
@@ -36,46 +92,7 @@ async def home(request: Request):
|
|||||||
# Get stats for current user
|
# Get stats for current user
|
||||||
stats = {}
|
stats = {}
|
||||||
if user:
|
if user:
|
||||||
try:
|
stats = await get_user_stats(user.actor_id)
|
||||||
stats["media"] = await database.count_user_items(user.actor_id)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
from ..services.recipe_service import RecipeService
|
|
||||||
from ..dependencies import get_redis_client, get_cache_manager
|
|
||||||
import logging
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
|
|
||||||
recipes = await recipe_service.list_recipes(user.actor_id)
|
|
||||||
stats["recipes"] = len(recipes)
|
|
||||||
logger.info(f"Home page: found {len(recipes)} recipes for {user.actor_id}")
|
|
||||||
except Exception as e:
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).error(f"Failed to get recipe count: {e}")
|
|
||||||
try:
|
|
||||||
from ..services.run_service import RunService
|
|
||||||
from ..dependencies import get_redis_client, get_cache_manager
|
|
||||||
run_service = RunService(database, get_redis_client(), get_cache_manager())
|
|
||||||
runs = await run_service.list_runs(user.actor_id)
|
|
||||||
stats["runs"] = len(runs)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
storage_providers = await database.get_user_storage_providers(user.actor_id)
|
|
||||||
stats["storage"] = len(storage_providers) if storage_providers else 0
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
# Effects are stored in _effects/ directory, not in cache
|
|
||||||
from pathlib import Path
|
|
||||||
from ..dependencies import get_cache_manager
|
|
||||||
effects_dir = Path(get_cache_manager().cache_dir) / "_effects"
|
|
||||||
if effects_dir.exists():
|
|
||||||
stats["effects"] = len([d for d in effects_dir.iterdir() if d.is_dir()])
|
|
||||||
else:
|
|
||||||
stats["effects"] = 0
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
templates = get_templates(request)
|
templates = get_templates(request)
|
||||||
return render(templates, "home.html", request,
|
return render(templates, "home.html", request,
|
||||||
|
|||||||
@@ -377,9 +377,10 @@ async def list_recipes(
|
|||||||
return RedirectResponse(url="/auth", status_code=302)
|
return RedirectResponse(url="/auth", status_code=302)
|
||||||
|
|
||||||
recipes = await recipe_service.list_recipes(ctx.actor_id, offset=offset, limit=limit)
|
recipes = await recipe_service.list_recipes(ctx.actor_id, offset=offset, limit=limit)
|
||||||
|
has_more = len(recipes) >= limit
|
||||||
|
|
||||||
if wants_json(request):
|
if wants_json(request):
|
||||||
return {"recipes": recipes, "offset": offset, "limit": limit}
|
return {"recipes": recipes, "offset": offset, "limit": limit, "has_more": has_more}
|
||||||
|
|
||||||
from ..dependencies import get_nav_counts
|
from ..dependencies import get_nav_counts
|
||||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||||
@@ -390,6 +391,9 @@ async def list_recipes(
|
|||||||
user=ctx,
|
user=ctx,
|
||||||
nav_counts=nav_counts,
|
nav_counts=nav_counts,
|
||||||
active_tab="recipes",
|
active_tab="recipes",
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
has_more=has_more,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,8 @@ async def storage_type_page(
|
|||||||
ctx = auth_service.get_user_from_cookie(request)
|
ctx = auth_service.get_user_from_cookie(request)
|
||||||
|
|
||||||
if not ctx:
|
if not ctx:
|
||||||
|
if wants_json(request):
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
return RedirectResponse(url="/auth", status_code=302)
|
return RedirectResponse(url="/auth", status_code=302)
|
||||||
|
|
||||||
if provider_type not in STORAGE_PROVIDERS_INFO:
|
if provider_type not in STORAGE_PROVIDERS_INFO:
|
||||||
@@ -279,6 +281,13 @@ async def storage_type_page(
|
|||||||
storages = await storage_service.list_by_type(ctx.actor_id, provider_type)
|
storages = await storage_service.list_by_type(ctx.actor_id, provider_type)
|
||||||
provider_info = STORAGE_PROVIDERS_INFO[provider_type]
|
provider_info = STORAGE_PROVIDERS_INFO[provider_type]
|
||||||
|
|
||||||
|
if wants_json(request):
|
||||||
|
return {
|
||||||
|
"provider_type": provider_type,
|
||||||
|
"provider_info": provider_info,
|
||||||
|
"storages": storages,
|
||||||
|
}
|
||||||
|
|
||||||
from ..dependencies import get_nav_counts
|
from ..dependencies import get_nav_counts
|
||||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if effects %}
|
{% if effects %}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="effects-list">
|
||||||
{% for effect in effects %}
|
{% for effect in effects %}
|
||||||
{% set meta = effect.meta or effect %}
|
{% set meta = effect.meta or effect %}
|
||||||
<a href="/effects/{{ effect.cid }}"
|
<a href="/effects/{{ effect.cid }}"
|
||||||
class="bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
|
class="effect-card bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<span class="font-medium text-white">{{ meta.name or 'Unnamed' }}</span>
|
<span class="font-medium text-white">{{ meta.name or 'Unnamed' }}</span>
|
||||||
<span class="text-gray-500 text-sm">v{{ meta.version or '1.0.0' }}</span>
|
<span class="text-gray-500 text-sm">v{{ meta.version or '1.0.0' }}</span>
|
||||||
@@ -70,6 +70,17 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if has_more %}
|
||||||
|
<div hx-get="/effects?offset={{ offset + limit }}&limit={{ limit }}"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-swap="afterend"
|
||||||
|
hx-select="#effects-list > *"
|
||||||
|
class="h-20 flex items-center justify-center text-gray-500">
|
||||||
|
Loading more...
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
||||||
<p class="text-gray-500 mb-4">No effects uploaded yet.</p>
|
<p class="text-gray-500 mb-4">No effects uploaded yet.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user