From 12a373a2a281343ccc047d385ab685693101b223 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Mar 2026 20:04:40 +0000 Subject: [PATCH] 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) --- backend/app/api/endpoints/admin_gallery.py | 191 +++++++ backend/app/api/router.py | 2 + backend/tests/test_admin_gallery.py | 312 +++++++++++ frontend/src/api/admin.ts | 31 ++ .../src/pages/admin/GalleryManagementPage.tsx | 498 ++++++++++++++++++ frontend/src/router.tsx | 2 + 6 files changed, 1036 insertions(+) create mode 100644 backend/app/api/endpoints/admin_gallery.py create mode 100644 backend/tests/test_admin_gallery.py create mode 100644 frontend/src/pages/admin/GalleryManagementPage.tsx diff --git a/backend/app/api/endpoints/admin_gallery.py b/backend/app/api/endpoints/admin_gallery.py new file mode 100644 index 00000000..8292bfb4 --- /dev/null +++ b/backend/app/api/endpoints/admin_gallery.py @@ -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) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index aeadb8dc..893224a1 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/tests/test_admin_gallery.py b/backend/tests/test_admin_gallery.py new file mode 100644 index 00000000..e611950a --- /dev/null +++ b/backend/tests/test_admin_gallery.py @@ -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 diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index ba013fcc..91ed8134 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -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(`/admin/gallery/flows/${id}/feature`, { is_gallery_featured }).then(r => r.data), + updateFlowSortOrder: (id: string, gallery_sort_order: number) => + api.patch(`/admin/gallery/flows/${id}/sort-order`, { gallery_sort_order }).then(r => r.data), + toggleScriptFeatured: (id: string, is_gallery_featured: boolean) => + api.patch(`/admin/gallery/scripts/${id}/feature`, { is_gallery_featured }).then(r => r.data), + updateScriptSortOrder: (id: string, gallery_sort_order: number) => + api.patch(`/admin/gallery/scripts/${id}/sort-order`, { gallery_sort_order }).then(r => r.data), } export default adminApi diff --git a/frontend/src/pages/admin/GalleryManagementPage.tsx b/frontend/src/pages/admin/GalleryManagementPage.tsx new file mode 100644 index 00000000..3241b483 --- /dev/null +++ b/frontend/src/pages/admin/GalleryManagementPage.tsx @@ -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 ( + + ) +} + +// --------------------------------------------------------------------------- +// 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(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 ( + 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 ( +
+
+ + 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', + )} + /> +
+
+ {(['all', 'featured', 'unfeatured'] as FilterMode[]).map(mode => ( + + ))} +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Flows table +// --------------------------------------------------------------------------- + +function FlowsTable({ + flows, + onToggleFeatured, + onSortOrder, + toggling, +}: { + flows: GalleryFlowItem[] + onToggleFeatured: (id: string, value: boolean) => void + onSortOrder: (id: string, value: number) => void + toggling: Set +}) { + if (flows.length === 0) { + return ( +

No flows match the current filter.

+ ) + } + + return ( +
+ + + + + + + + + + + {flows.map(flow => ( + + + + + + + ))} + +
NameTypeFeaturedSort Order
{flow.name} + + {flow.tree_type} + + +
+ onToggleFeatured(flow.id, v)} + disabled={toggling.has(flow.id)} + /> + {flow.is_gallery_featured && ( + + )} +
+
+ onSortOrder(flow.id, v)} + disabled={toggling.has(flow.id)} + /> +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Scripts table +// --------------------------------------------------------------------------- + +function ScriptsTable({ + scripts, + onToggleFeatured, + onSortOrder, + toggling, +}: { + scripts: GalleryScriptItem[] + onToggleFeatured: (id: string, value: boolean) => void + onSortOrder: (id: string, value: number) => void + toggling: Set +}) { + if (scripts.length === 0) { + return ( +

No scripts match the current filter.

+ ) + } + + return ( +
+ + + + + + + + + + + {scripts.map(script => ( + + + + + + + ))} + +
NameStatusFeaturedSort Order
{script.name} + + {script.is_active ? 'Active' : 'Inactive'} + + +
+ onToggleFeatured(script.id, v)} + disabled={toggling.has(script.id)} + /> + {script.is_gallery_featured && ( + + )} +
+
+ onSortOrder(script.id, v)} + disabled={toggling.has(script.id)} + /> +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- + +export default function GalleryManagementPage() { + const [flows, setFlows] = useState([]) + const [scripts, setScripts] = useState([]) + const [loading, setLoading] = useState(true) + + // Per-table filter state + const [flowSearch, setFlowSearch] = useState('') + const [flowFilter, setFlowFilter] = useState('all') + const [scriptSearch, setScriptSearch] = useState('') + const [scriptFilter, setScriptFilter] = useState('all') + + // Track in-flight requests per item + const [toggling, setToggling] = useState>(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 ( +
+ window.open('/templates', '_blank')} + > + + Preview Gallery + + } + /> + + {loading ? ( +
+ Loading gallery items… +
+ ) : ( +
+ {/* Summary stats */} +
+
+ + + {featuredFlowCount} featured flow{featuredFlowCount !== 1 ? 's' : ''} + +
+
+ + + {featuredScriptCount} featured script{featuredScriptCount !== 1 ? 's' : ''} + +
+
+ + {/* Featured Flows section */} +
+
+
+

Featured Flows

+

+ Toggle flows to show in the gallery. Lower sort order = shown first. +

+
+
+ +
+ + +
+
+ + {/* Featured Scripts section */} +
+
+
+

Featured Scripts

+

+ Toggle scripts to show in the gallery. Lower sort order = shown first. +

+
+
+ +
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index ba25eef2..37abb902 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -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