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
tree_id: string
tree_name: string
tree_type: 'troubleshooting' | 'procedural'
tree_type: 'troubleshooting' | 'procedural' | 'maintenance'
category_emoji?: string
category_name?: string
pinned_at: string

View File

@@ -1,25 +1,2 @@
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
export { EmptyState } from '@/components/common/EmptyState'
export { default } from '@/components/common/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() {
return (
<div className="flex h-screen items-center justify-center bg-black">
<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>
</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 { Folder, ChevronDown, ChevronRight, Plus, MoreVertical, Pencil, Trash2, FolderPlus, X } from 'lucide-react'
import { foldersApi } from '@/api/folders'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import type { FolderListItem, FolderTreeItem } from '@/types'
import { cn } from '@/lib/utils'
@@ -245,6 +246,7 @@ export function FolderSidebar({
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const [pendingDelete, setPendingDelete] = useState<{ id: string; message: string } | null>(null)
const loadFolders = useCallback(async () => {
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 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? The trees in it will not be deleted.'
if (!confirm(message)) {
return
}
setPendingDelete({ id: folderId, message })
}
const confirmDeleteFolder = async () => {
if (!pendingDelete) return
const folderId = pendingDelete.id
setPendingDelete(null)
try {
await foldersApi.delete(folderId)
// Remove folder and all descendants from local state
@@ -494,6 +500,15 @@ export function FolderSidebar({
</button>
</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)`}
>
<span className="text-sm shrink-0">
{flow.tree_type === 'procedural' ? '📋' : '🔧'}
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
</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" />

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
import { useState, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
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 { toast } from '@/lib/toast'
import { sessionsApi } from '@/api/sessions'
@@ -47,6 +50,7 @@ export default function MySharesPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copiedId, setCopiedId] = useState<string | null>(null)
const [revokeTarget, setRevokeTarget] = useState<SessionShare | null>(null)
const fetchShares = useCallback(async () => {
try {
@@ -77,18 +81,16 @@ export default function MySharesPage() {
}
}
const handleRevoke = async (share: SessionShare) => {
const confirmed = window.confirm(
'Revoke this share link? Anyone with the link will no longer be able to access the session.'
)
if (!confirmed) return
const handleRevoke = async () => {
if (!revokeTarget) return
try {
await sessionsApi.revokeShare(share.id)
setShares((prev) => prev.filter((s) => s.id !== share.id))
await sessionsApi.revokeShare(revokeTarget.id)
setShares((prev) => prev.filter((s) => s.id !== revokeTarget.id))
toast.success('Share link revoked')
} catch {
toast.error('Failed to revoke share link')
} finally {
setRevokeTarget(null)
}
}
@@ -96,7 +98,7 @@ export default function MySharesPage() {
if (loading) {
return (
<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>
)
}
@@ -139,18 +141,20 @@ export default function MySharesPage() {
{/* Empty state */}
{shares.length === 0 ? (
<div className="bg-card border border-border rounded-xl p-12 text-center">
<Link2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">No shared sessions</h2>
<p className="text-muted-foreground text-sm mb-6">
Share a session from the session detail page to create a link
</p>
<button
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"
>
Go to Sessions
</button>
<div className="bg-card border border-border rounded-xl">
<EmptyState
icon={<Link2 className="h-12 w-12" />}
title="No shared sessions"
description="Share a session from the session detail page to create a link"
action={
<button
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"
>
Go to Sessions
</button>
}
/>
</div>
) : (
<div className="space-y-4">
@@ -223,7 +227,7 @@ export default function MySharesPage() {
</Link>
<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"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -235,6 +239,15 @@ export default function MySharesPage() {
})}
</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>
)
}

View File

@@ -7,6 +7,7 @@ import type { TreeListItem } from '@/types'
import { TagBadges } from '@/components/common/TagBadges'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { ShareTreeModal } from '@/components/library/ShareTreeModal'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
@@ -177,7 +178,7 @@ export function MyTreesPage() {
{/* Loading State */}
{isLoading ? (
<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>
) : trees.length === 0 ? (
<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 { StepList } from '@/components/procedural-editor/StepList'
import { TagInput } from '@/components/common/TagInput'
import { Spinner } from '@/components/common/Spinner'
import { toast } from '@/lib/toast'
import type { TreeType, MaintenanceSchedule, TargetList } from '@/types'
@@ -143,7 +144,7 @@ export function ProceduralEditorPage() {
if (isLoading) {
return (
<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>
)
}

View File

@@ -10,6 +10,7 @@ import { StepDetail } from '@/components/procedural/StepDetail'
import { ProgressBar } from '@/components/procedural/ProgressBar'
import { CompletionSummary } from '@/components/procedural/CompletionSummary'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { StepFeedback } from '@/components/session/StepFeedback'
@@ -290,7 +291,7 @@ export function ProceduralNavigationPage() {
if (isLoading) {
return (
<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>
)
}

View File

@@ -13,6 +13,7 @@ import type { MenuAction } from '@/components/common/ActionMenu'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
import { Spinner } from '@/components/common/Spinner'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
@@ -289,7 +290,7 @@ export function SessionDetailPage() {
if (isLoading) {
return (
<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>
)
}

View File

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

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react'
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 { sessionsApi } from '@/api/sessions'
import { Spinner } from '@/components/common/Spinner'
import { BrandLogo } from '@/components/common/BrandLogo'
import { SessionTimeline } from '@/components/session/SessionTimeline'
import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview'
@@ -144,7 +145,7 @@ export function SharedSessionPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<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>
</div>
</div>

View File

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

View File

@@ -11,6 +11,7 @@ import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
import { ValidationSummary } from '@/components/tree-editor/ValidationSummary'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { usePermissions } from '@/hooks/usePermissions'
import { Spinner } from '@/components/common/Spinner'
import { cn, safeGetItem } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
@@ -382,7 +383,7 @@ export function TreeEditorPage() {
if (isLoading) {
return (
<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>
)
}

View File

@@ -11,6 +11,7 @@ import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
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 { Spinner } from '@/components/common/Spinner'
import { toast } from '@/lib/toast'
import { Modal } from '@/components/common/Modal'
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
@@ -528,7 +529,7 @@ export function TreeNavigationPage() {
if (isLoading) {
return (
<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>
)
}