refactor: shared components, ConfirmDialog migration, pinned flow fixes

- Create shared Spinner component with sm/md/lg sizes
- Migrate 13 page-level spinners to shared Spinner
- Promote EmptyState to shared component, adopt in MyShares and SessionHistory
- Replace window.confirm with ConfirmDialog in 3 files
- Fix PinnedFlow.tree_type to include maintenance, update emoji display
- Verify sidebar unpin handler already correct (no-op)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-19 14:32:01 -05:00
parent 779dff5b5e
commit c309a0ba84
20 changed files with 167 additions and 87 deletions

View File

@@ -4,7 +4,7 @@ export interface PinnedFlow {
id: string id: string
tree_id: string tree_id: string
tree_name: string tree_name: string
tree_type: 'troubleshooting' | 'procedural' tree_type: 'troubleshooting' | 'procedural' | 'maintenance'
category_emoji?: string category_emoji?: string
category_name?: string category_name?: string
pinned_at: string pinned_at: string

View File

@@ -1,25 +1,2 @@
import type { ReactNode } from 'react' export { EmptyState } from '@/components/common/EmptyState'
import { cn } from '@/lib/utils' export { default } from '@/components/common/EmptyState'
interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
action?: ReactNode
className?: string
}
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
)
}
export default EmptyState

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
action?: ReactNode
className?: string
}
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
)
}
export default EmptyState

View File

@@ -1,8 +1,10 @@
import { Spinner } from '@/components/common/Spinner'
export function PageLoader() { export function PageLoader() {
return ( return (
<div className="flex h-screen items-center justify-center bg-black"> <div className="flex h-screen items-center justify-center bg-black">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-border border-t-foreground" /> <Spinner size="lg" />
<p className="text-sm text-muted-foreground">Loading...</p> <p className="text-sm text-muted-foreground">Loading...</p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,26 @@
import { cn } from '@/lib/utils'
const SIZES = {
sm: 'h-4 w-4 border-2',
md: 'h-8 w-8 border-4',
lg: 'h-12 w-12 border-4',
} as const
interface SpinnerProps {
size?: keyof typeof SIZES
className?: string
}
export function Spinner({ size = 'md', className }: SpinnerProps) {
return (
<div
className={cn(
'animate-spin rounded-full border-border border-t-primary',
SIZES[size],
className
)}
/>
)
}
export default Spinner

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus, X } from 'lucide-react' import { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus, X } from 'lucide-react'
import { foldersApi } from '@/api/folders' import { foldersApi } from '@/api/folders'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import type { FolderListItem, FolderTreeItem } from '@/types' import type { FolderListItem, FolderTreeItem } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -245,6 +246,7 @@ export function FolderSidebar({
const [menuOpenId, setMenuOpenId] = useState<string | null>(null) const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()) const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null) const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const [pendingDelete, setPendingDelete] = useState<{ id: string; message: string } | null>(null)
const loadFolders = useCallback(async () => { const loadFolders = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
@@ -277,15 +279,19 @@ export function FolderSidebar({
}) })
} }
const handleDeleteFolder = async (folderId: string, folderHasChildren: boolean) => { const handleDeleteFolder = (folderId: string, folderHasChildren: boolean) => {
const descendantCount = getDescendantIds(folders, folderId).length const descendantCount = getDescendantIds(folders, folderId).length
const message = folderHasChildren const message = folderHasChildren
? `Are you sure you want to delete this folder and its ${descendantCount} subfolder(s)? The trees in them will not be deleted.` ? `Are you sure you want to delete this folder and its ${descendantCount} subfolder(s)? The trees in them will not be deleted.`
: 'Are you sure you want to delete this folder? The trees in it will not be deleted.' : 'Are you sure you want to delete this folder? The trees in it will not be deleted.'
if (!confirm(message)) { setPendingDelete({ id: folderId, message })
return }
}
const confirmDeleteFolder = async () => {
if (!pendingDelete) return
const folderId = pendingDelete.id
setPendingDelete(null)
try { try {
await foldersApi.delete(folderId) await foldersApi.delete(folderId)
// Remove folder and all descendants from local state // Remove folder and all descendants from local state
@@ -494,6 +500,15 @@ export function FolderSidebar({
</button> </button>
</div> </div>
)} )}
<ConfirmDialog
isOpen={!!pendingDelete}
onClose={() => setPendingDelete(null)}
onConfirm={confirmDeleteFolder}
title="Delete Folder"
message={pendingDelete?.message || ''}
confirmLabel="Delete"
/>
</> </>
) )
} }

View File

@@ -46,7 +46,7 @@ export function PinnedFlowsSection({ flows, onUnpin }: PinnedFlowsSectionProps)
title={`${flow.tree_name} (right-click to unpin)`} title={`${flow.tree_name} (right-click to unpin)`}
> >
<span className="text-sm shrink-0"> <span className="text-sm shrink-0">
{flow.tree_type === 'procedural' ? '📋' : '🔧'} {flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
</span> </span>
<span className="truncate flex-1 text-left">{flow.tree_name}</span> <span className="truncate flex-1 text-left">{flow.tree_name}</span>
<Pin size={12} className="shrink-0 opacity-0 group-hover:opacity-40 transition-opacity" /> <Pin size={12} className="shrink-0 opacity-0 group-hover:opacity-40 transition-opacity" />

View File

@@ -4,6 +4,7 @@ import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
import { NodeFormDecision } from './NodeFormDecision' import { NodeFormDecision } from './NodeFormDecision'
import { NodeFormAction } from './NodeFormAction' import { NodeFormAction } from './NodeFormAction'
import { NodeFormResolution } from './NodeFormResolution' import { NodeFormResolution } from './NodeFormResolution'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { TreeStructure, NodeType } from '@/types' import type { TreeStructure, NodeType } from '@/types'
@@ -36,6 +37,7 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
const [draft, setDraft] = useState<TreeStructure | null>(null) const [draft, setDraft] = useState<TreeStructure | null>(null)
const [isDirty, setIsDirty] = useState(false) const [isDirty, setIsDirty] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
const panelRef = useRef<HTMLDivElement>(null) const panelRef = useRef<HTMLDivElement>(null)
// Initialize/reset draft when nodeId changes // Initialize/reset draft when nodeId changes
@@ -84,7 +86,8 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
if (isDirty) { if (isDirty) {
if (!window.confirm('You have unsaved changes. Discard them?')) return setShowDiscardConfirm(true)
return
} }
onClose() onClose()
}, [isDirty, onClose]) }, [isDirty, onClose])
@@ -236,6 +239,18 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
</> </>
)} )}
</div> </div>
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={() => {
setShowDiscardConfirm(false)
onClose()
}}
title="Discard Changes"
message="You have unsaved changes. Discard them?"
confirmLabel="Discard"
/>
</div> </div>
) )
} }

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText } from 'lucide-react' import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText } from 'lucide-react'
import { accountsApi } from '@/api/accounts' import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types' import type { Account, AccountMember, AccountInvite } from '@/types'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
import { useSubscription } from '@/hooks/useSubscription' import { useSubscription } from '@/hooks/useSubscription'
@@ -130,7 +131,7 @@ export function AccountSettingsPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" /> <Spinner />
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { BarChart3, Loader2, Target, Clock, TrendingUp, CheckCircle } from 'lucide-react' import { BarChart3, Target, Clock, TrendingUp, CheckCircle } from 'lucide-react'
import { import {
AreaChart, AreaChart,
Area, Area,
@@ -10,6 +10,7 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from 'recharts' } from 'recharts'
import { Spinner } from '@/components/common/Spinner'
import { analyticsApi } from '@/api' import { analyticsApi } from '@/api'
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
import type { PersonalAnalyticsResponse, AnalyticsPeriod } from '@/types' import type { PersonalAnalyticsResponse, AnalyticsPeriod } from '@/types'
@@ -45,7 +46,7 @@ export default function MyAnalyticsPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<Loader2 size={32} className="animate-spin text-muted-foreground" /> <Spinner />
</div> </div>
) )
} }

View File

@@ -1,6 +1,9 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react' import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { sessionsApi } from '@/api/sessions' import { sessionsApi } from '@/api/sessions'
@@ -47,6 +50,7 @@ export default function MySharesPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [copiedId, setCopiedId] = useState<string | null>(null) const [copiedId, setCopiedId] = useState<string | null>(null)
const [revokeTarget, setRevokeTarget] = useState<SessionShare | null>(null)
const fetchShares = useCallback(async () => { const fetchShares = useCallback(async () => {
try { try {
@@ -77,18 +81,16 @@ export default function MySharesPage() {
} }
} }
const handleRevoke = async (share: SessionShare) => { const handleRevoke = async () => {
const confirmed = window.confirm( if (!revokeTarget) return
'Revoke this share link? Anyone with the link will no longer be able to access the session.'
)
if (!confirmed) return
try { try {
await sessionsApi.revokeShare(share.id) await sessionsApi.revokeShare(revokeTarget.id)
setShares((prev) => prev.filter((s) => s.id !== share.id)) setShares((prev) => prev.filter((s) => s.id !== revokeTarget.id))
toast.success('Share link revoked') toast.success('Share link revoked')
} catch { } catch {
toast.error('Failed to revoke share link') toast.error('Failed to revoke share link')
} finally {
setRevokeTarget(null)
} }
} }
@@ -96,7 +98,7 @@ export default function MySharesPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-32"> <div className="flex items-center justify-center py-32">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" /> <Spinner />
</div> </div>
) )
} }
@@ -139,18 +141,20 @@ export default function MySharesPage() {
{/* Empty state */} {/* Empty state */}
{shares.length === 0 ? ( {shares.length === 0 ? (
<div className="bg-card border border-border rounded-xl p-12 text-center"> <div className="bg-card border border-border rounded-xl">
<Link2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <EmptyState
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">No shared sessions</h2> icon={<Link2 className="h-12 w-12" />}
<p className="text-muted-foreground text-sm mb-6"> title="No shared sessions"
Share a session from the session detail page to create a link description="Share a session from the session detail page to create a link"
</p> action={
<button <button
onClick={() => navigate('/sessions')} onClick={() => navigate('/sessions')}
className="bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90 rounded-md px-4 py-2 text-sm font-medium transition-colors" className="bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
> >
Go to Sessions Go to Sessions
</button> </button>
}
/>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -223,7 +227,7 @@ export default function MySharesPage() {
</Link> </Link>
<button <button
onClick={() => handleRevoke(share)} onClick={() => setRevokeTarget(share)}
className="inline-flex items-center gap-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-md px-3 py-1.5 text-sm transition-colors" className="inline-flex items-center gap-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-md px-3 py-1.5 text-sm transition-colors"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
@@ -235,6 +239,15 @@ export default function MySharesPage() {
})} })}
</div> </div>
)} )}
<ConfirmDialog
isOpen={!!revokeTarget}
onClose={() => setRevokeTarget(null)}
onConfirm={handleRevoke}
title="Revoke Share Link"
message="Revoke this share link? Anyone with the link will no longer be able to access the session."
confirmLabel="Revoke"
/>
</div> </div>
) )
} }

View File

@@ -7,6 +7,7 @@ import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges' import { TagBadges } from '@/components/common/TagBadges'
import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { ShareTreeModal } from '@/components/library/ShareTreeModal' import { ShareTreeModal } from '@/components/library/ShareTreeModal'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/authStore' import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
@@ -177,7 +178,7 @@ export function MyTreesPage() {
{/* Loading State */} {/* Loading State */}
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" /> <Spinner />
</div> </div>
) : trees.length === 0 ? ( ) : trees.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-accent px-4 py-12 text-center"> <div className="rounded-lg border border-dashed border-border bg-accent px-4 py-12 text-center">

View File

@@ -9,6 +9,7 @@ import { MaintenanceScheduleSection } from '@/components/procedural-editor/Maint
import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils' import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils'
import { StepList } from '@/components/procedural-editor/StepList' import { StepList } from '@/components/procedural-editor/StepList'
import { TagInput } from '@/components/common/TagInput' import { TagInput } from '@/components/common/TagInput'
import { Spinner } from '@/components/common/Spinner'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import type { TreeType, MaintenanceSchedule, TargetList } from '@/types' import type { TreeType, MaintenanceSchedule, TargetList } from '@/types'
@@ -143,7 +144,7 @@ export function ProceduralEditorPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex min-h-[50vh] items-center justify-center"> <div className="flex min-h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" /> <Spinner />
</div> </div>
) )
} }

View File

@@ -10,6 +10,7 @@ import { StepDetail } from '@/components/procedural/StepDetail'
import { ProgressBar } from '@/components/procedural/ProgressBar' import { ProgressBar } from '@/components/procedural/ProgressBar'
import { CompletionSummary } from '@/components/procedural/CompletionSummary' import { CompletionSummary } from '@/components/procedural/CompletionSummary'
import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { StepFeedback } from '@/components/session/StepFeedback' import { StepFeedback } from '@/components/session/StepFeedback'
@@ -290,7 +291,7 @@ export function ProceduralNavigationPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex min-h-[50vh] items-center justify-center"> <div className="flex min-h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" /> <Spinner />
</div> </div>
) )
} }

View File

@@ -13,6 +13,7 @@ import type { MenuAction } from '@/components/common/ActionMenu'
import { useUserPreferencesStore } from '@/store/userPreferencesStore' import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types' import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings' import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
@@ -289,7 +290,7 @@ export function SessionDetailPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-primary" /> <Spinner />
</div> </div>
) )
} }

View File

@@ -6,6 +6,8 @@ import type { Session, TreeListItem } from '@/types'
import type { DateRange } from 'react-day-picker' import type { DateRange } from 'react-day-picker'
import { SessionFilters } from '@/components/session/SessionFilters' import { SessionFilters } from '@/components/session/SessionFilters'
import type { SessionFilterState } from '@/components/session/SessionFilters' import type { SessionFilterState } from '@/components/session/SessionFilters'
import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { getSessionResumePath } from '@/lib/routing' import { getSessionResumePath } from '@/lib/routing'
@@ -188,27 +190,22 @@ export function SessionHistoryPage() {
{/* Loading State */} {/* Loading State */}
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-primary" /> <Spinner />
</div> </div>
) : sessions.length === 0 ? ( ) : sessions.length === 0 ? (
<div className="py-12 text-center text-muted-foreground"> <EmptyState
No sessions found.{' '} title="No sessions found"
{filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from ? ( description={filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from
<button ? "Try adjusting your filters"
onClick={handleClearFilters} : "Complete a flow to see it here"}
className="text-foreground hover:underline" action={
> (filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? (
Clear filters <button onClick={handleClearFilters} className="text-foreground hover:underline text-sm">
</button> Clear all filters
) : ( </button>
<button ) : undefined
onClick={() => navigate('/trees')} }
className="text-foreground hover:underline" />
>
Start a new session
</button>
)}
</div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{sessions.map((session) => ( {sessions.map((session) => (

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { Globe, Users, ShieldAlert, FileX, Clock, Loader2 } from 'lucide-react' import { Globe, Users, ShieldAlert, FileX, Clock } from 'lucide-react'
import { isAxiosError } from 'axios' import { isAxiosError } from 'axios'
import { sessionsApi } from '@/api/sessions' import { sessionsApi } from '@/api/sessions'
import { Spinner } from '@/components/common/Spinner'
import { BrandLogo } from '@/components/common/BrandLogo' import { BrandLogo } from '@/components/common/BrandLogo'
import { SessionTimeline } from '@/components/session/SessionTimeline' import { SessionTimeline } from '@/components/session/SessionTimeline'
import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview' import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview'
@@ -144,7 +145,7 @@ export function SharedSessionPage() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background"> <div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Spinner />
<p className="text-sm text-muted-foreground">Loading shared session...</p> <p className="text-sm text-muted-foreground">Loading shared session...</p>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link, Navigate } from 'react-router-dom' import { Link, Navigate } from 'react-router-dom'
import { BarChart3, Loader2, Users, Target, Clock, TrendingUp } from 'lucide-react' import { BarChart3, Users, Target, Clock, TrendingUp } from 'lucide-react'
import { import {
AreaChart, AreaChart,
Area, Area,
@@ -10,6 +10,7 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from 'recharts' } from 'recharts'
import { Spinner } from '@/components/common/Spinner'
import { analyticsApi } from '@/api' import { analyticsApi } from '@/api'
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
import type { TeamAnalyticsResponse, AnalyticsPeriod } from '@/types' import type { TeamAnalyticsResponse, AnalyticsPeriod } from '@/types'
@@ -51,7 +52,7 @@ export default function TeamAnalyticsPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[60vh]"> <div className="flex items-center justify-center min-h-[60vh]">
<Loader2 size={32} className="animate-spin text-muted-foreground" /> <Spinner />
</div> </div>
) )
} }

View File

@@ -11,6 +11,7 @@ import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
import { ValidationSummary } from '@/components/tree-editor/ValidationSummary' import { ValidationSummary } from '@/components/tree-editor/ValidationSummary'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
import { Spinner } from '@/components/common/Spinner'
import { cn, safeGetItem } from '@/lib/utils' import { cn, safeGetItem } from '@/lib/utils'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel' import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
@@ -382,7 +383,7 @@ export function TreeEditorPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" /> <Spinner />
</div> </div>
) )
} }

View File

@@ -11,6 +11,7 @@ import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal' import { CustomStepModal } from '@/components/step-library/CustomStepModal'
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session' import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle, Link2, ChevronDown, Settings } from 'lucide-react' import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle, Link2, ChevronDown, Settings } from 'lucide-react'
import { Spinner } from '@/components/common/Spinner'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { Modal } from '@/components/common/Modal' import { Modal } from '@/components/common/Modal'
import { ShareSessionModal } from '@/components/session/ShareSessionModal' import { ShareSessionModal } from '@/components/session/ShareSessionModal'
@@ -528,7 +529,7 @@ export function TreeNavigationPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-foreground" /> <Spinner />
</div> </div>
) )
} }