feat: AI-assisted flow builder with 4-stage wizard #87
@@ -1,17 +1,25 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Search, Plus, Loader2 } from 'lucide-react'
|
||||
import { Search, Plus, Loader2, Star, ChevronDown, ChevronLeft, ChevronRight, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import type { Session } from '@/types/session'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePinnedFlowsStore, selectPinnedTreeIds, selectPinLoadingTreeIds } from '@/store/pinnedFlowsStore'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { usePaginationParams } from '@/hooks/usePaginationParams'
|
||||
import { useCachedQuota } from '@/hooks/useCachedQuota'
|
||||
import { QuickStats } from '@/components/dashboard/QuickStats'
|
||||
import { FiltersBar } from '@/components/dashboard/FiltersBar'
|
||||
import { SectionGroup } from '@/components/dashboard/SectionGroup'
|
||||
import { SessionsPanel } from '@/components/dashboard/SessionsPanel'
|
||||
import { TreeListItem as TreeListItemComponent } from '@/components/dashboard/TreeListItem'
|
||||
import { TreeGridView } from '@/components/library/TreeGridView'
|
||||
import { TreeListView } from '@/components/library/TreeListView'
|
||||
import { TreeTableView } from '@/components/library/TreeTableView'
|
||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
@@ -30,7 +38,9 @@ function timeAgo(dateStr: string): string {
|
||||
export function QuickStartPage() {
|
||||
const navigate = useNavigate()
|
||||
const { canCreateTrees } = usePermissions()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
// Search state
|
||||
const [query, setQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<TreeListItem[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
@@ -38,34 +48,106 @@ export function QuickStartPage() {
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||
// Sessions state
|
||||
const [activeSessions, setActiveSessions] = useState<Session[]>([])
|
||||
const [allSessions, setAllSessions] = useState<Session[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const [activeFilter, setActiveFilter] = useState('all')
|
||||
// My Flows state
|
||||
const [myFlows, setMyFlows] = useState<TreeListItem[]>([])
|
||||
const [isLoadingFlows, setIsLoadingFlows] = useState(true)
|
||||
const [hasNextPage, setHasNextPage] = useState(false)
|
||||
const [allFlowsCeiling, setAllFlowsCeiling] = useState(false)
|
||||
|
||||
// Load data on mount
|
||||
// Favorites state
|
||||
const [showAllFavorites, setShowAllFavorites] = useState(false)
|
||||
|
||||
// Create menu + AI Builder
|
||||
const [showCreateMenu, setShowCreateMenu] = useState(false)
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const { aiEnabled } = useCachedQuota()
|
||||
|
||||
// Pin store
|
||||
const pinnedItems = usePinnedFlowsStore((s) => s.items)
|
||||
const pinnedIsLoading = usePinnedFlowsStore((s) => s.isLoading)
|
||||
const loadPinned = usePinnedFlowsStore((s) => s.load)
|
||||
const pinnedTreeIds = usePinnedFlowsStore(selectPinnedTreeIds)
|
||||
const pinLoadingTreeIds = usePinnedFlowsStore(selectPinLoadingTreeIds)
|
||||
const togglePin = usePinnedFlowsStore((s) => s.toggle)
|
||||
|
||||
// Preferences
|
||||
const { dashboardMyFlowsView, setDashboardMyFlowsView } = useUserPreferencesStore()
|
||||
|
||||
// Pagination
|
||||
const { page, pageSize, setPage, setPageSize } = usePaginationParams({
|
||||
defaultPageSize: 10,
|
||||
allowedPageSizes: [10, 25, 50, 'all'],
|
||||
})
|
||||
|
||||
// Load pinned flows
|
||||
useEffect(() => { loadPinned() }, [loadPinned])
|
||||
|
||||
// Load sessions on mount
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const [treeList, active, recent] = await Promise.all([
|
||||
treesApi.list({ sort_by: 'updated_at' }),
|
||||
sessionsApi.list({ completed: false, size: 5 }),
|
||||
sessionsApi.list({ size: 10 }),
|
||||
])
|
||||
setTrees(treeList)
|
||||
setActiveSessions(active)
|
||||
setAllSessions(recent)
|
||||
} catch (err) {
|
||||
console.error('Failed to load dashboard data:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
Promise.all([
|
||||
sessionsApi.list({ completed: false, size: 5 }).catch(() => []),
|
||||
sessionsApi.list({ size: 10 }).catch(() => []),
|
||||
]).then(([active, recent]) => {
|
||||
setActiveSessions(active)
|
||||
setAllSessions(recent)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Load my flows when page/size or user changes
|
||||
useEffect(() => {
|
||||
if (!user?.id) return
|
||||
|
||||
const loadFlows = async () => {
|
||||
setIsLoadingFlows(true)
|
||||
setAllFlowsCeiling(false)
|
||||
|
||||
if (pageSize === 'all') {
|
||||
// Fetch in chunks of 100, max 500
|
||||
let allItems: TreeListItem[] = []
|
||||
let skip = 0
|
||||
const CHUNK = 100
|
||||
const MAX = 500
|
||||
|
||||
while (true) {
|
||||
const chunk = await treesApi.list({
|
||||
author_id: user.id,
|
||||
sort_by: 'updated_at',
|
||||
limit: CHUNK,
|
||||
skip,
|
||||
})
|
||||
allItems = [...allItems, ...chunk]
|
||||
if (chunk.length < CHUNK || allItems.length >= MAX) {
|
||||
if (allItems.length >= MAX) {
|
||||
allItems = allItems.slice(0, MAX)
|
||||
setAllFlowsCeiling(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
skip += CHUNK
|
||||
}
|
||||
setMyFlows(allItems)
|
||||
setHasNextPage(false)
|
||||
} else {
|
||||
const numSize = pageSize as number
|
||||
const response = await treesApi.list({
|
||||
author_id: user.id,
|
||||
sort_by: 'updated_at',
|
||||
limit: numSize + 1,
|
||||
skip: (page - 1) * numSize,
|
||||
})
|
||||
setHasNextPage(response.length > numSize)
|
||||
setMyFlows(response.slice(0, numSize))
|
||||
}
|
||||
setIsLoadingFlows(false)
|
||||
}
|
||||
|
||||
loadFlows().catch(() => setIsLoadingFlows(false))
|
||||
}, [user?.id, page, pageSize])
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
@@ -90,7 +172,7 @@ export function QuickStartPage() {
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query])
|
||||
|
||||
// Close dropdown on outside click
|
||||
// Close search dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
||||
@@ -101,8 +183,7 @@ export function QuickStartPage() {
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
// Compute stats
|
||||
const totalTrees = trees.length
|
||||
// Stats
|
||||
const openSessions = activeSessions.length
|
||||
const todaySessions = allSessions.filter(s => {
|
||||
const d = new Date(s.started_at)
|
||||
@@ -111,14 +192,6 @@ export function QuickStartPage() {
|
||||
}).length
|
||||
const completedSessions = allSessions.filter(s => s.completed_at).length
|
||||
|
||||
// Filter trees
|
||||
const filteredTrees = activeFilter === 'all'
|
||||
? trees
|
||||
: activeFilter === 'recent'
|
||||
? trees.slice(0, 10)
|
||||
: trees
|
||||
|
||||
// Map sessions for SessionsPanel
|
||||
const recentSessionItems = allSessions.slice(0, 5).map(s => ({
|
||||
id: s.id,
|
||||
treeName: s.tree_snapshot?.name || 'Unknown',
|
||||
@@ -127,12 +200,28 @@ export function QuickStartPage() {
|
||||
timeAgo: timeAgo(s.started_at),
|
||||
}))
|
||||
|
||||
const filters = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'recent', label: 'Recently Used' },
|
||||
{ id: 'my', label: 'My Flows' },
|
||||
{ id: 'team', label: 'Team Flows' },
|
||||
]
|
||||
// Favorites display
|
||||
const MAX_VISIBLE_FAVORITES = 8
|
||||
const visibleFavorites = showAllFavorites ? pinnedItems : pinnedItems.slice(0, MAX_VISIBLE_FAVORITES)
|
||||
const hasMoreFavorites = pinnedItems.length > MAX_VISIBLE_FAVORITES
|
||||
|
||||
// Handlers
|
||||
const handleStartSession = (treeId: string, treeType?: string) => {
|
||||
if (treeType === 'maintenance') {
|
||||
navigate(`/flows/${treeId}/maintenance`)
|
||||
} else if (treeType === 'procedural') {
|
||||
navigate(`/flows/${treeId}/navigate`)
|
||||
} else {
|
||||
navigate(`/trees/${treeId}/navigate`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTree = () => {} // Not used on dashboard
|
||||
const handleTagClick = () => {} // Not used on dashboard
|
||||
const handleFolderCreated = () => {} // Not used on dashboard
|
||||
|
||||
// Page size options
|
||||
const pageSizeOptions: (number | 'all')[] = [10, 25, 50, 'all']
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -148,13 +237,75 @@ export function QuickStartPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canCreateTrees && (
|
||||
<Link
|
||||
to="/trees/new"
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create Flow
|
||||
</Link>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowCreateMenu(!showCreateMenu)}
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create Flow
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{showCreateMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowCreateMenu(false)} />
|
||||
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-card p-1 shadow-xl backdrop-blur-sm">
|
||||
<Link
|
||||
to="/trees/new"
|
||||
onClick={() => setShowCreateMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Troubleshooting Tree</div>
|
||||
<div className="text-xs text-muted-foreground">Branching decision flow</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/flows/new"
|
||||
onClick={() => setShowCreateMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Procedural Flow</div>
|
||||
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/flows/new?type=maintenance"
|
||||
onClick={() => setShowCreateMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Wrench className="h-4 w-4 text-amber-400" />
|
||||
<div>
|
||||
<div className="font-medium">Maintenance Flow</div>
|
||||
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
||||
</div>
|
||||
</Link>
|
||||
{aiEnabled && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateMenu(false)
|
||||
setShowAIBuilder(true)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Build with AI</div>
|
||||
<div className="text-xs text-muted-foreground">AI-assisted flow creation</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,17 +313,14 @@ export function QuickStartPage() {
|
||||
{/* Quick Stats */}
|
||||
<QuickStats
|
||||
stats={[
|
||||
{ label: 'Active Flows', value: totalTrees, gradient: true },
|
||||
{ label: 'My Flows', value: myFlows.length, gradient: true },
|
||||
{ label: 'Sessions Today', value: todaySessions, color: '#f59e0b' },
|
||||
{ label: 'Open Sessions', value: openSessions, meta: `${completedSessions} completed` },
|
||||
{ label: 'Docs Generated', value: completedSessions },
|
||||
{ label: 'Favorites', value: pinnedItems.length },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<FiltersBar filters={filters} activeFilter={activeFilter} onFilterChange={setActiveFilter} />
|
||||
|
||||
{/* Search (inline, not hero) */}
|
||||
{/* Search */}
|
||||
<div ref={searchRef} className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
@@ -215,39 +363,214 @@ export function QuickStartPage() {
|
||||
{/* Recent Sessions */}
|
||||
<SessionsPanel sessions={recentSessionItems} delay={150} />
|
||||
|
||||
{/* Tree/Flow List */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<SectionGroup
|
||||
title="All Flows"
|
||||
count={filteredTrees.length}
|
||||
delay={200}
|
||||
>
|
||||
{filteredTrees.slice(0, 20).map(tree => (
|
||||
<TreeListItemComponent
|
||||
key={tree.id}
|
||||
id={tree.id}
|
||||
name={tree.name}
|
||||
description={tree.description}
|
||||
treeType={tree.tree_type || 'troubleshooting'}
|
||||
category={tree.category_info ? { name: tree.category_info.name, color: undefined } : null}
|
||||
tags={tree.tags}
|
||||
usageCount={tree.usage_count}
|
||||
updatedAt={tree.updated_at}
|
||||
/>
|
||||
))}
|
||||
{filteredTrees.length > 20 && (
|
||||
<Link
|
||||
to="/trees"
|
||||
className="block rounded-lg border border-border bg-card px-4 py-3 text-center text-sm text-muted-foreground hover:text-foreground hover:border-border/80 transition-colors"
|
||||
{/* Favorites Section */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">
|
||||
Favorites
|
||||
{pinnedItems.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">({pinnedItems.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{hasMoreFavorites && (
|
||||
<button
|
||||
onClick={() => setShowAllFavorites(!showAllFavorites)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View all {filteredTrees.length} flows →
|
||||
</Link>
|
||||
{showAllFavorites ? 'Show less' : 'View all favorites'}
|
||||
</button>
|
||||
)}
|
||||
</SectionGroup>
|
||||
</div>
|
||||
{pinnedIsLoading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-20 rounded-xl bg-card border border-border animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : pinnedItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
Star a flow to pin it here for quick access.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{visibleFavorites.map((flow) => (
|
||||
<button
|
||||
key={flow.tree_id}
|
||||
onClick={() => navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))}
|
||||
className="group relative flex items-center gap-3 rounded-xl bg-card border border-border p-4 text-left transition-colors hover:border-border/80 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-lg shrink-0">
|
||||
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-foreground">{flow.tree_name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
togglePin(flow.tree_id)
|
||||
}}
|
||||
aria-label="Remove from favorites"
|
||||
className="absolute top-2 right-2 rounded-md p-1 text-amber-400 opacity-0 group-hover:opacity-100 hover:text-amber-300 transition-all"
|
||||
>
|
||||
<Star size={14} fill="currentColor" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My Flows Section */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">My Flows</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingFlows ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-32 rounded-xl bg-card border border-border animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : myFlows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground mb-4">You haven't created any flows yet.</p>
|
||||
{canCreateTrees && (
|
||||
<button
|
||||
onClick={() => setShowCreateMenu(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create your first flow
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allFlowsCeiling && (
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Showing first 500 flows. Use search or filters to find specific flows.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{dashboardMyFlowsView === 'grid' && (
|
||||
<TreeGridView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{dashboardMyFlowsView === 'list' && (
|
||||
<TreeListView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{dashboardMyFlowsView === 'table' && (
|
||||
<TreeTableView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination controls */}
|
||||
{pageSize !== 'all' && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
|
||||
page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={!hasNextPage}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
|
||||
!hasNextPage ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Show:</span>
|
||||
<select
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
|
||||
}}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={String(opt)} value={String(opt)}>
|
||||
{opt === 'all' ? 'All' : opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pageSize === 'all' && (
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Show:</span>
|
||||
<select
|
||||
value="all"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
|
||||
}}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={String(opt)} value={String(opt)}>
|
||||
{opt === 'all' ? 'All' : opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Builder Modal */}
|
||||
{showAIBuilder && (
|
||||
<AIFlowBuilderModal
|
||||
isOpen={showAIBuilder}
|
||||
onClose={() => setShowAIBuilder(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user