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:
2026-03-19 20:04:40 +00:00
parent 2b657fc4ac
commit 12a373a2a2
6 changed files with 1036 additions and 0 deletions

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

View File

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

View 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

View File

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

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

View File

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