feat(gallery): add admin gallery curation endpoints and management page (Task 6)
Add super-admin-only backend endpoints for toggling is_gallery_featured and gallery_sort_order on flows and scripts, plus a frontend GalleryManagementPage with toggle switches, editable sort order fields, and name/featured filters. 13 integration tests; all pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
191
backend/app/api/endpoints/admin_gallery.py
Normal file
191
backend/app/api/endpoints/admin_gallery.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Admin gallery curation endpoints.
|
||||
|
||||
Allows super admins to toggle is_gallery_featured and update gallery_sort_order
|
||||
on Tree (flows) and ScriptTemplate (scripts) records.
|
||||
"""
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_admin
|
||||
from app.core.database import get_db
|
||||
from app.models.script_template import ScriptTemplate
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/admin/gallery", tags=["admin-gallery"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FeatureToggle(BaseModel):
|
||||
is_gallery_featured: bool
|
||||
|
||||
|
||||
class SortOrderUpdate(BaseModel):
|
||||
gallery_sort_order: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _flow_summary(tree: Tree) -> dict:
|
||||
return {
|
||||
"id": str(tree.id),
|
||||
"name": tree.name,
|
||||
"tree_type": tree.tree_type,
|
||||
"is_gallery_featured": tree.is_gallery_featured,
|
||||
"gallery_sort_order": tree.gallery_sort_order,
|
||||
"visibility": tree.visibility,
|
||||
}
|
||||
|
||||
|
||||
def _script_summary(script: ScriptTemplate) -> dict:
|
||||
return {
|
||||
"id": str(script.id),
|
||||
"name": script.name,
|
||||
"is_gallery_featured": script.is_gallery_featured,
|
||||
"gallery_sort_order": script.gallery_sort_order,
|
||||
"is_active": script.is_active,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/featured")
|
||||
async def list_featured(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""List all featured flows and scripts (super admin only)."""
|
||||
flows_result = await db.execute(
|
||||
select(Tree)
|
||||
.where(Tree.is_gallery_featured == True) # noqa: E712
|
||||
.order_by(Tree.gallery_sort_order.asc(), Tree.name.asc())
|
||||
)
|
||||
flows = flows_result.scalars().all()
|
||||
|
||||
scripts_result = await db.execute(
|
||||
select(ScriptTemplate)
|
||||
.where(ScriptTemplate.is_gallery_featured == True) # noqa: E712
|
||||
.order_by(ScriptTemplate.gallery_sort_order.asc(), ScriptTemplate.name.asc())
|
||||
)
|
||||
scripts = scripts_result.scalars().all()
|
||||
|
||||
return {
|
||||
"flows": [_flow_summary(f) for f in flows],
|
||||
"scripts": [_script_summary(s) for s in scripts],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/items")
|
||||
async def list_all_items(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""List ALL flows and scripts with their gallery status (super admin only)."""
|
||||
flows_result = await db.execute(
|
||||
select(Tree)
|
||||
.where(Tree.visibility == "public")
|
||||
.order_by(Tree.gallery_sort_order.asc(), Tree.name.asc())
|
||||
)
|
||||
flows = flows_result.scalars().all()
|
||||
|
||||
scripts_result = await db.execute(
|
||||
select(ScriptTemplate)
|
||||
.order_by(ScriptTemplate.gallery_sort_order.asc(), ScriptTemplate.name.asc())
|
||||
)
|
||||
scripts = scripts_result.scalars().all()
|
||||
|
||||
return {
|
||||
"flows": [_flow_summary(f) for f in flows],
|
||||
"scripts": [_script_summary(s) for s in scripts],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/flows/{flow_id}/feature")
|
||||
async def toggle_flow_featured(
|
||||
flow_id: UUID,
|
||||
body: FeatureToggle,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Toggle is_gallery_featured on a flow (super admin only)."""
|
||||
result = await db.execute(select(Tree).where(Tree.id == flow_id))
|
||||
tree = result.scalar_one_or_none()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Flow not found")
|
||||
|
||||
tree.is_gallery_featured = body.is_gallery_featured
|
||||
await db.commit()
|
||||
await db.refresh(tree)
|
||||
return _flow_summary(tree)
|
||||
|
||||
|
||||
@router.patch("/flows/{flow_id}/sort-order")
|
||||
async def update_flow_sort_order(
|
||||
flow_id: UUID,
|
||||
body: SortOrderUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update gallery_sort_order on a flow (super admin only)."""
|
||||
result = await db.execute(select(Tree).where(Tree.id == flow_id))
|
||||
tree = result.scalar_one_or_none()
|
||||
if not tree:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Flow not found")
|
||||
|
||||
tree.gallery_sort_order = body.gallery_sort_order
|
||||
await db.commit()
|
||||
await db.refresh(tree)
|
||||
return _flow_summary(tree)
|
||||
|
||||
|
||||
@router.patch("/scripts/{script_id}/feature")
|
||||
async def toggle_script_featured(
|
||||
script_id: UUID,
|
||||
body: FeatureToggle,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Toggle is_gallery_featured on a script (super admin only)."""
|
||||
result = await db.execute(select(ScriptTemplate).where(ScriptTemplate.id == script_id))
|
||||
script = result.scalar_one_or_none()
|
||||
if not script:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found")
|
||||
|
||||
script.is_gallery_featured = body.is_gallery_featured
|
||||
await db.commit()
|
||||
await db.refresh(script)
|
||||
return _script_summary(script)
|
||||
|
||||
|
||||
@router.patch("/scripts/{script_id}/sort-order")
|
||||
async def update_script_sort_order(
|
||||
script_id: UUID,
|
||||
body: SortOrderUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update gallery_sort_order on a script (super admin only)."""
|
||||
result = await db.execute(select(ScriptTemplate).where(ScriptTemplate.id == script_id))
|
||||
script = result.scalar_one_or_none()
|
||||
if not script:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found")
|
||||
|
||||
script.gallery_sort_order = body.gallery_sort_order
|
||||
await db.commit()
|
||||
await db.refresh(script)
|
||||
return _script_summary(script)
|
||||
@@ -26,6 +26,7 @@ from app.api.endpoints import flow_proposals
|
||||
from app.api.endpoints import flowpilot_analytics
|
||||
from app.api.endpoints import notifications
|
||||
from app.api.endpoints import public_templates
|
||||
from app.api.endpoints import admin_gallery
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -77,3 +78,4 @@ api_router.include_router(flow_proposals.router)
|
||||
api_router.include_router(flowpilot_analytics.router)
|
||||
api_router.include_router(notifications.router)
|
||||
api_router.include_router(public_templates.router)
|
||||
api_router.include_router(admin_gallery.router)
|
||||
|
||||
312
backend/tests/test_admin_gallery.py
Normal file
312
backend/tests/test_admin_gallery.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""Integration tests for admin gallery curation endpoints."""
|
||||
|
||||
import uuid
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tree import Tree
|
||||
from app.models.script_template import ScriptTemplate, ScriptCategory
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_tree(db: AsyncSession, admin_user_id: str) -> Tree:
|
||||
"""Insert a minimal Tree directly into the DB."""
|
||||
tree = Tree(
|
||||
id=uuid.uuid4(),
|
||||
name="Gallery Test Flow",
|
||||
tree_type="troubleshooting",
|
||||
visibility="public",
|
||||
is_gallery_featured=False,
|
||||
gallery_sort_order=0,
|
||||
tree_structure={
|
||||
"id": "root",
|
||||
"type": "decision",
|
||||
"question": "Test?",
|
||||
"options": [],
|
||||
"children": [],
|
||||
},
|
||||
author_id=uuid.UUID(admin_user_id),
|
||||
)
|
||||
db.add(tree)
|
||||
await db.commit()
|
||||
await db.refresh(tree)
|
||||
return tree
|
||||
|
||||
|
||||
async def _create_script(db: AsyncSession, admin_user_id: str) -> ScriptTemplate:
|
||||
"""Insert a minimal ScriptTemplate directly into the DB."""
|
||||
# Need a category first
|
||||
category = ScriptCategory(
|
||||
id=uuid.uuid4(),
|
||||
name="Test Category",
|
||||
slug=f"test-category-{uuid.uuid4().hex[:6]}",
|
||||
)
|
||||
db.add(category)
|
||||
await db.flush()
|
||||
|
||||
script = ScriptTemplate(
|
||||
id=uuid.uuid4(),
|
||||
category_id=category.id,
|
||||
name="Gallery Test Script",
|
||||
slug=f"gallery-test-script-{uuid.uuid4().hex[:6]}",
|
||||
script_body="Write-Host 'Test'",
|
||||
is_gallery_featured=False,
|
||||
gallery_sort_order=0,
|
||||
created_by=uuid.UUID(admin_user_id) if admin_user_id else None,
|
||||
)
|
||||
db.add(script)
|
||||
await db.commit()
|
||||
await db.refresh(script)
|
||||
return script
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdminGallery:
|
||||
"""Test suite for admin gallery curation endpoints."""
|
||||
|
||||
# -- Auth guard tests --
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_featured_list_requires_admin(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""Non-admin users get 403 on featured list."""
|
||||
response = await client.get("/api/v1/admin/gallery/featured", headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_toggle_flow_requires_admin(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""Non-admin users get 403 when trying to toggle flow feature."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/flows/{fake_id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_toggle_script_requires_admin(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""Non-admin users get 403 when trying to toggle script feature."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/scripts/{fake_id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
# -- Feature toggle tests --
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_flow_featured_on(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""Admin can feature a flow."""
|
||||
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/flows/{tree.id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_gallery_featured"] is True
|
||||
assert data["id"] == str(tree.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_flow_featured_off(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""Admin can un-feature a previously featured flow."""
|
||||
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
# Feature it first
|
||||
tree.is_gallery_featured = True
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/flows/{tree.id}/feature",
|
||||
json={"is_gallery_featured": False},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["is_gallery_featured"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_flow_not_found(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Returns 404 when flow ID doesn't exist."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/flows/{fake_id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_script_featured_on(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""Admin can feature a script."""
|
||||
script = await _create_script(test_db, test_admin["user_data"]["id"])
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/scripts/{script.id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["is_gallery_featured"] is True
|
||||
assert data["id"] == str(script.id)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_script_not_found(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""Returns 404 when script ID doesn't exist."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/scripts/{fake_id}/feature",
|
||||
json={"is_gallery_featured": True},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
# -- Sort order tests --
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_flow_sort_order(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""Admin can update gallery sort order for a flow."""
|
||||
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/flows/{tree.id}/sort-order",
|
||||
json={"gallery_sort_order": 42},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["gallery_sort_order"] == 42
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_script_sort_order(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""Admin can update gallery sort order for a script."""
|
||||
script = await _create_script(test_db, test_admin["user_data"]["id"])
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/gallery/scripts/{script.id}/sort-order",
|
||||
json={"gallery_sort_order": 7},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["gallery_sort_order"] == 7
|
||||
|
||||
# -- Featured list tests --
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_featured_list_returns_only_featured(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""GET /featured returns only items where is_gallery_featured=True."""
|
||||
# Create two flows: one featured, one not
|
||||
featured_tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
_unfeatured_tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
_unfeatured_tree.name = "Unfeatured Flow"
|
||||
|
||||
# Feature only the first
|
||||
featured_tree.is_gallery_featured = True
|
||||
await test_db.commit()
|
||||
|
||||
# Create two scripts: one featured, one not
|
||||
featured_script = await _create_script(test_db, test_admin["user_data"]["id"])
|
||||
_unfeatured_script = await _create_script(test_db, test_admin["user_data"]["id"])
|
||||
featured_script.is_gallery_featured = True
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/gallery/featured", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
flow_ids = [f["id"] for f in data["flows"]]
|
||||
script_ids = [s["id"] for s in data["scripts"]]
|
||||
|
||||
assert str(featured_tree.id) in flow_ids
|
||||
assert str(_unfeatured_tree.id) not in flow_ids
|
||||
assert str(featured_script.id) in script_ids
|
||||
assert str(_unfeatured_script.id) not in script_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_items_list_requires_admin(
|
||||
self, client: AsyncClient, auth_headers: dict
|
||||
):
|
||||
"""Non-admin users get 403 on all items list."""
|
||||
response = await client.get("/api/v1/admin/gallery/items", headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_items_list_returns_all(
|
||||
self,
|
||||
client: AsyncClient,
|
||||
admin_auth_headers: dict,
|
||||
test_admin: dict,
|
||||
test_db: AsyncSession,
|
||||
):
|
||||
"""GET /items returns all flows and scripts regardless of featured status."""
|
||||
tree = await _create_tree(test_db, test_admin["user_data"]["id"])
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/gallery/items", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "flows" in data
|
||||
assert "scripts" in data
|
||||
flow_ids = [f["id"] for f in data["flows"]]
|
||||
assert str(tree.id) in flow_ids
|
||||
@@ -50,6 +50,23 @@ export interface SurveyResponseListResponse {
|
||||
unread: number
|
||||
}
|
||||
|
||||
export interface GalleryFlowItem {
|
||||
id: string
|
||||
name: string
|
||||
tree_type: string
|
||||
is_gallery_featured: boolean
|
||||
gallery_sort_order: number
|
||||
visibility: string
|
||||
}
|
||||
|
||||
export interface GalleryScriptItem {
|
||||
id: string
|
||||
name: string
|
||||
is_gallery_featured: boolean
|
||||
gallery_sort_order: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
// Dashboard
|
||||
getDashboardMetrics: () =>
|
||||
@@ -194,6 +211,20 @@ export const adminApi = {
|
||||
api.delete(`/admin/survey-responses/${id}`),
|
||||
bulkActionResponses: (action: string, ids: string[]) =>
|
||||
api.post('/admin/survey-responses/bulk', { action, ids }).then(r => r.data),
|
||||
|
||||
// Gallery Curation
|
||||
getGalleryFeatured: () =>
|
||||
api.get<{ flows: GalleryFlowItem[]; scripts: GalleryScriptItem[] }>('/admin/gallery/featured').then(r => r.data),
|
||||
getGalleryAllItems: () =>
|
||||
api.get<{ flows: GalleryFlowItem[]; scripts: GalleryScriptItem[] }>('/admin/gallery/items').then(r => r.data),
|
||||
toggleFlowFeatured: (id: string, is_gallery_featured: boolean) =>
|
||||
api.patch<GalleryFlowItem>(`/admin/gallery/flows/${id}/feature`, { is_gallery_featured }).then(r => r.data),
|
||||
updateFlowSortOrder: (id: string, gallery_sort_order: number) =>
|
||||
api.patch<GalleryFlowItem>(`/admin/gallery/flows/${id}/sort-order`, { gallery_sort_order }).then(r => r.data),
|
||||
toggleScriptFeatured: (id: string, is_gallery_featured: boolean) =>
|
||||
api.patch<GalleryScriptItem>(`/admin/gallery/scripts/${id}/feature`, { is_gallery_featured }).then(r => r.data),
|
||||
updateScriptSortOrder: (id: string, gallery_sort_order: number) =>
|
||||
api.patch<GalleryScriptItem>(`/admin/gallery/scripts/${id}/sort-order`, { gallery_sort_order }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default adminApi
|
||||
|
||||
498
frontend/src/pages/admin/GalleryManagementPage.tsx
Normal file
498
frontend/src/pages/admin/GalleryManagementPage.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { ExternalLink, Star, Search } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { PageHeader } from '@/components/admin'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import type { GalleryFlowItem, GalleryScriptItem } from '@/api/admin'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toggle switch component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/40 focus:ring-offset-2 focus:ring-offset-background',
|
||||
checked ? 'bg-primary' : 'bg-[rgba(255,255,255,0.1)]',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-lg ring-0 transition duration-200',
|
||||
checked ? 'translate-x-4' : 'translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sort order input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SortOrderInput({
|
||||
value,
|
||||
onSave,
|
||||
disabled,
|
||||
}: {
|
||||
value: number
|
||||
onSave: (v: number) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [local, setLocal] = useState(String(value))
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Sync if external value changes
|
||||
useEffect(() => {
|
||||
setLocal(String(value))
|
||||
}, [value])
|
||||
|
||||
const commit = () => {
|
||||
const parsed = parseInt(local, 10)
|
||||
if (!isNaN(parsed) && parsed !== value) {
|
||||
onSave(parsed)
|
||||
} else {
|
||||
setLocal(String(value)) // revert invalid input
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="number"
|
||||
value={local}
|
||||
disabled={disabled}
|
||||
onChange={e => setLocal(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
commit()
|
||||
ref.current?.blur()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'w-20 rounded-[8px] border border-border bg-card px-2 py-1 text-sm text-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FilterMode = 'all' | 'featured' | 'unfeatured'
|
||||
|
||||
function FilterBar({
|
||||
search,
|
||||
onSearchChange,
|
||||
filter,
|
||||
onFilterChange,
|
||||
}: {
|
||||
search: string
|
||||
onSearchChange: (v: string) => void
|
||||
filter: FilterMode
|
||||
onFilterChange: (v: FilterMode) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[180px] max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name…"
|
||||
value={search}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-[10px] border border-border bg-card pl-9 pr-3 py-2 text-sm text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex rounded-[10px] border border-border overflow-hidden text-sm">
|
||||
{(['all', 'featured', 'unfeatured'] as FilterMode[]).map(mode => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => onFilterChange(mode)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 capitalize transition-colors',
|
||||
filter === mode
|
||||
? 'bg-primary text-[#101114] font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground bg-card',
|
||||
)}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flows table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FlowsTable({
|
||||
flows,
|
||||
onToggleFeatured,
|
||||
onSortOrder,
|
||||
toggling,
|
||||
}: {
|
||||
flows: GalleryFlowItem[]
|
||||
onToggleFeatured: (id: string, value: boolean) => void
|
||||
onSortOrder: (id: string, value: number) => void
|
||||
toggling: Set<string>
|
||||
}) {
|
||||
if (flows.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">No flows match the current filter.</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="pb-3 pr-4 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Name</th>
|
||||
<th className="pb-3 pr-4 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Type</th>
|
||||
<th className="pb-3 pr-4 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Featured</th>
|
||||
<th className="pb-3 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Sort Order</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{flows.map(flow => (
|
||||
<tr key={flow.id} className="group hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<td className="py-3 pr-4 text-foreground font-medium">{flow.name}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] rounded-full px-2 py-0.5 border border-border text-muted-foreground">
|
||||
{flow.tree_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToggleSwitch
|
||||
checked={flow.is_gallery_featured}
|
||||
onChange={v => onToggleFeatured(flow.id, v)}
|
||||
disabled={toggling.has(flow.id)}
|
||||
/>
|
||||
{flow.is_gallery_featured && (
|
||||
<Star className="h-3 w-3 text-amber-400 fill-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<SortOrderInput
|
||||
value={flow.gallery_sort_order}
|
||||
onSave={v => onSortOrder(flow.id, v)}
|
||||
disabled={toggling.has(flow.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scripts table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ScriptsTable({
|
||||
scripts,
|
||||
onToggleFeatured,
|
||||
onSortOrder,
|
||||
toggling,
|
||||
}: {
|
||||
scripts: GalleryScriptItem[]
|
||||
onToggleFeatured: (id: string, value: boolean) => void
|
||||
onSortOrder: (id: string, value: number) => void
|
||||
toggling: Set<string>
|
||||
}) {
|
||||
if (scripts.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">No scripts match the current filter.</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="pb-3 pr-4 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Name</th>
|
||||
<th className="pb-3 pr-4 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Status</th>
|
||||
<th className="pb-3 pr-4 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Featured</th>
|
||||
<th className="pb-3 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Sort Order</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{scripts.map(script => (
|
||||
<tr key={script.id} className="group hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||
<td className="py-3 pr-4 text-foreground font-medium">{script.name}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span
|
||||
className={cn(
|
||||
'font-label text-[0.625rem] uppercase tracking-[0.1em] rounded-full px-2 py-0.5 border',
|
||||
script.is_active
|
||||
? 'border-emerald-400/30 text-emerald-400 bg-emerald-400/10'
|
||||
: 'border-border text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{script.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToggleSwitch
|
||||
checked={script.is_gallery_featured}
|
||||
onChange={v => onToggleFeatured(script.id, v)}
|
||||
disabled={toggling.has(script.id)}
|
||||
/>
|
||||
{script.is_gallery_featured && (
|
||||
<Star className="h-3 w-3 text-amber-400 fill-amber-400" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<SortOrderInput
|
||||
value={script.gallery_sort_order}
|
||||
onSave={v => onSortOrder(script.id, v)}
|
||||
disabled={toggling.has(script.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function GalleryManagementPage() {
|
||||
const [flows, setFlows] = useState<GalleryFlowItem[]>([])
|
||||
const [scripts, setScripts] = useState<GalleryScriptItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Per-table filter state
|
||||
const [flowSearch, setFlowSearch] = useState('')
|
||||
const [flowFilter, setFlowFilter] = useState<FilterMode>('all')
|
||||
const [scriptSearch, setScriptSearch] = useState('')
|
||||
const [scriptFilter, setScriptFilter] = useState<FilterMode>('all')
|
||||
|
||||
// Track in-flight requests per item
|
||||
const [toggling, setToggling] = useState<Set<string>>(new Set())
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.getGalleryAllItems()
|
||||
setFlows(data.flows)
|
||||
setScripts(data.scripts)
|
||||
} catch {
|
||||
toast.error('Failed to load gallery items')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
// Flow actions
|
||||
const handleToggleFlow = async (id: string, value: boolean) => {
|
||||
setToggling(prev => new Set(prev).add(id))
|
||||
try {
|
||||
const updated = await adminApi.toggleFlowFeatured(id, value)
|
||||
setFlows(prev => prev.map(f => f.id === id ? { ...f, ...updated } : f))
|
||||
toast.success(value ? 'Flow added to gallery' : 'Flow removed from gallery')
|
||||
} catch {
|
||||
toast.error('Failed to update flow')
|
||||
} finally {
|
||||
setToggling(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
}
|
||||
}
|
||||
|
||||
const handleFlowSortOrder = async (id: string, value: number) => {
|
||||
setToggling(prev => new Set(prev).add(id))
|
||||
try {
|
||||
const updated = await adminApi.updateFlowSortOrder(id, value)
|
||||
setFlows(prev => prev.map(f => f.id === id ? { ...f, ...updated } : f))
|
||||
} catch {
|
||||
toast.error('Failed to update sort order')
|
||||
} finally {
|
||||
setToggling(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
}
|
||||
}
|
||||
|
||||
// Script actions
|
||||
const handleToggleScript = async (id: string, value: boolean) => {
|
||||
setToggling(prev => new Set(prev).add(id))
|
||||
try {
|
||||
const updated = await adminApi.toggleScriptFeatured(id, value)
|
||||
setScripts(prev => prev.map(s => s.id === id ? { ...s, ...updated } : s))
|
||||
toast.success(value ? 'Script added to gallery' : 'Script removed from gallery')
|
||||
} catch {
|
||||
toast.error('Failed to update script')
|
||||
} finally {
|
||||
setToggling(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
}
|
||||
}
|
||||
|
||||
const handleScriptSortOrder = async (id: string, value: number) => {
|
||||
setToggling(prev => new Set(prev).add(id))
|
||||
try {
|
||||
const updated = await adminApi.updateScriptSortOrder(id, value)
|
||||
setScripts(prev => prev.map(s => s.id === id ? { ...s, ...updated } : s))
|
||||
} catch {
|
||||
toast.error('Failed to update sort order')
|
||||
} finally {
|
||||
setToggling(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
}
|
||||
}
|
||||
|
||||
// Filtering
|
||||
const filteredFlows = flows.filter(f => {
|
||||
const matchesSearch = f.name.toLowerCase().includes(flowSearch.toLowerCase())
|
||||
const matchesFilter =
|
||||
flowFilter === 'all' ||
|
||||
(flowFilter === 'featured' && f.is_gallery_featured) ||
|
||||
(flowFilter === 'unfeatured' && !f.is_gallery_featured)
|
||||
return matchesSearch && matchesFilter
|
||||
})
|
||||
|
||||
const filteredScripts = scripts.filter(s => {
|
||||
const matchesSearch = s.name.toLowerCase().includes(scriptSearch.toLowerCase())
|
||||
const matchesFilter =
|
||||
scriptFilter === 'all' ||
|
||||
(scriptFilter === 'featured' && s.is_gallery_featured) ||
|
||||
(scriptFilter === 'unfeatured' && !s.is_gallery_featured)
|
||||
return matchesSearch && matchesFilter
|
||||
})
|
||||
|
||||
const featuredFlowCount = flows.filter(f => f.is_gallery_featured).length
|
||||
const featuredScriptCount = scripts.filter(s => s.is_gallery_featured).length
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Gallery Management"
|
||||
description="Control which flows and scripts appear in the public template gallery."
|
||||
action={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open('/templates', '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-1.5" />
|
||||
Preview Gallery
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground text-sm">
|
||||
Loading gallery items…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Summary stats */}
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="glass-card-static rounded-[12px] px-4 py-3 flex items-center gap-3">
|
||||
<Star className="h-4 w-4 text-amber-400 fill-amber-400" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-foreground font-semibold">{featuredFlowCount}</span> featured flow{featuredFlowCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="glass-card-static rounded-[12px] px-4 py-3 flex items-center gap-3">
|
||||
<Star className="h-4 w-4 text-amber-400 fill-amber-400" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-foreground font-semibold">{featuredScriptCount}</span> featured script{featuredScriptCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Flows section */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Featured Flows</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Toggle flows to show in the gallery. Lower sort order = shown first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static rounded-[16px] p-5 space-y-4">
|
||||
<FilterBar
|
||||
search={flowSearch}
|
||||
onSearchChange={setFlowSearch}
|
||||
filter={flowFilter}
|
||||
onFilterChange={setFlowFilter}
|
||||
/>
|
||||
<FlowsTable
|
||||
flows={filteredFlows}
|
||||
onToggleFeatured={handleToggleFlow}
|
||||
onSortOrder={handleFlowSortOrder}
|
||||
toggling={toggling}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Scripts section */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Featured Scripts</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Toggle scripts to show in the gallery. Lower sort order = shown first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static rounded-[16px] p-5 space-y-4">
|
||||
<FilterBar
|
||||
search={scriptSearch}
|
||||
onSearchChange={setScriptSearch}
|
||||
filter={scriptFilter}
|
||||
onFilterChange={setScriptFilter}
|
||||
/>
|
||||
<ScriptsTable
|
||||
scripts={filteredScripts}
|
||||
onToggleFeatured={handleToggleScript}
|
||||
onSortOrder={handleScriptSortOrder}
|
||||
toggling={toggling}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -67,6 +67,7 @@ const AdminSettingsPage = lazy(() => import('@/pages/admin/SettingsPage'))
|
||||
const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategoriesPage'))
|
||||
const AdminSurveyInvitesPage = lazy(() => import('@/pages/admin/SurveyInvitesPage'))
|
||||
const AdminSurveyResponsesPage = lazy(() => import('@/pages/admin/SurveyResponsesPage'))
|
||||
const AdminGalleryManagementPage = lazy(() => import('@/pages/admin/GalleryManagementPage'))
|
||||
|
||||
// Account pages
|
||||
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
|
||||
@@ -210,6 +211,7 @@ export const router = sentryCreateBrowserRouter([
|
||||
{ path: 'categories', element: page(AdminGlobalCategoriesPage) },
|
||||
{ path: 'survey-invites', element: page(AdminSurveyInvitesPage) },
|
||||
{ path: 'survey-responses', element: page(AdminSurveyResponsesPage) },
|
||||
{ path: 'gallery', element: page(AdminGalleryManagementPage) },
|
||||
],
|
||||
},
|
||||
// Account routes
|
||||
|
||||
Reference in New Issue
Block a user