mirror of
https://github.com/reconurge/flowsint.git
synced 2026-03-09 07:17:07 -05:00
feat(app): mentionlist
This commit is contained in:
BIN
flowsint-app/.DS_Store
vendored
Normal file
BIN
flowsint-app/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -57,6 +57,7 @@
|
||||
"@tiptap/extension-horizontal-rule": "^2.12.0",
|
||||
"@tiptap/extension-image": "^2.12.0",
|
||||
"@tiptap/extension-link": "^2.12.0",
|
||||
"@tiptap/extension-mention": "2.12.0",
|
||||
"@tiptap/extension-placeholder": "^2.12.0",
|
||||
"@tiptap/extension-subscript": "^2.12.0",
|
||||
"@tiptap/extension-superscript": "^2.12.0",
|
||||
@@ -70,6 +71,7 @@
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/react": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/suggestion": "^3.15.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
PlusIcon,
|
||||
Trash2,
|
||||
Save,
|
||||
ChevronDown,
|
||||
ChevronsRight,
|
||||
ExternalLink,
|
||||
@@ -30,11 +29,13 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { queryKeys } from '@/api/query-keys'
|
||||
import { useCreateAnalysis } from '@/hooks/use-create-analysis'
|
||||
import { SaveStatusBadge } from '@/components/shared/save-status-badge'
|
||||
|
||||
interface AnalysisEditorProps {
|
||||
// Core data
|
||||
analysis: Analysis | null
|
||||
investigationId: string
|
||||
ownerId?: string
|
||||
|
||||
// Callbacks
|
||||
onAnalysisUpdate?: (analysis: Analysis) => void
|
||||
@@ -83,7 +84,6 @@ export const AnalysisEditor = ({
|
||||
type: string
|
||||
}
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// State/refs for editor
|
||||
const editorContentRef = useRef<any>('')
|
||||
const [titleValue, setTitleValue] = useState('')
|
||||
@@ -340,7 +340,7 @@ export const AnalysisEditor = ({
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-md font-medium cursor-pointer hover:text-primary truncate min-w-0 flex-1"
|
||||
className={`text-md font-medium truncate min-w-0 flex-1 ${'cursor-pointer hover:text-primary'}`}
|
||||
onClick={() => setIsEditingTitle(true)}
|
||||
>
|
||||
{titleValue || 'Untitled Analysis'}
|
||||
@@ -352,32 +352,7 @@ export const AnalysisEditor = ({
|
||||
{/* Action buttons */}
|
||||
{showActions && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleManualSave}
|
||||
disabled={!analysis || saveMutation.isPending}
|
||||
title={
|
||||
saveStatus === 'saved'
|
||||
? 'Saved'
|
||||
: saveStatus === 'saving'
|
||||
? 'Saving...'
|
||||
: 'Save'
|
||||
}
|
||||
className="h-8 w-8 relative"
|
||||
>
|
||||
<Save className="w-4 h-4" strokeWidth={1.5} />
|
||||
{saveStatus === 'saved' && (
|
||||
<div className="absolute top-1 right-1 w-2 h-2 bg-green-500 rounded-full" />
|
||||
)}
|
||||
{saveStatus === 'saving' && (
|
||||
<div className="absolute top-1 right-1 w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
{saveStatus === 'unsaved' && (
|
||||
<div className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<SaveStatusBadge status={saveStatus} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div>
|
||||
@@ -402,6 +377,7 @@ export const AnalysisEditor = ({
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{type !== 'analysis' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => createMutation.mutate()}
|
||||
@@ -454,10 +430,9 @@ export const AnalysisEditor = ({
|
||||
className="w-full h-full"
|
||||
editorContentClassName="p-5 min-h-[300px]"
|
||||
output="json"
|
||||
placeholder="Enter your analysis..."
|
||||
placeholder={'Enter your analysis...'}
|
||||
autofocus={true}
|
||||
showToolbar={showToolbar}
|
||||
editable={true}
|
||||
editorClassName="focus:outline-hidden"
|
||||
onEditorReady={setEditor}
|
||||
/>
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './selection'
|
||||
export * from './unset-all-marks'
|
||||
export * from './reset-marks-on-enter'
|
||||
export * from './file-handler'
|
||||
export * from './mention'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './mention'
|
||||
@@ -0,0 +1,115 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ItemType } from '@/stores/node-display-settings'
|
||||
import { useIcon } from '@/hooks/use-icon'
|
||||
|
||||
export interface MentionItem {
|
||||
value: string
|
||||
type: ItemType
|
||||
nodeId: string
|
||||
}
|
||||
|
||||
export interface MentionListProps {
|
||||
items: MentionItem[]
|
||||
command: (item: { label: string; type: ItemType; nodeId: string }) => void
|
||||
}
|
||||
|
||||
export interface MentionListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean
|
||||
}
|
||||
|
||||
const MentionList = forwardRef<MentionListRef, MentionListProps>((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index]
|
||||
|
||||
if (item) {
|
||||
props.command({ label: item.value, type: item.type, nodeId: item.nodeId })
|
||||
}
|
||||
}
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length)
|
||||
}
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length)
|
||||
}
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex)
|
||||
}
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
upHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
downHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
enterHandler()
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="z-50 min-w-32 max-w-[400px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
|
||||
{props.items.length ? (
|
||||
<div className="overflow-y-auto max-h-[300px]">
|
||||
{props.items.map((item, index) => (
|
||||
<MentionListItem
|
||||
item={item}
|
||||
index={index}
|
||||
selectedIndex={selectedIndex}
|
||||
selectItem={selectItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">No results</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
type MentionItemProps = {
|
||||
item: MentionItem
|
||||
index: number
|
||||
selectedIndex: number
|
||||
selectItem: (index: number) => void
|
||||
}
|
||||
const MentionListItem = ({ item, index, selectedIndex, selectItem }: MentionItemProps) => {
|
||||
const Icon = useIcon(item.type, null) ?? null
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex w-full justify-start cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
index === selectedIndex && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
type="button"
|
||||
>
|
||||
{Icon && <Icon size={14} />}
|
||||
<span className="flex-1 text-left truncate text-ellipsis">{item.value}</span>
|
||||
<span className="text-xs text-muted-foreground capitalize">{item.type}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
MentionList.displayName = 'MentionList'
|
||||
|
||||
export default MentionList
|
||||
@@ -0,0 +1,231 @@
|
||||
import { computePosition, flip, shift } from '@floating-ui/dom'
|
||||
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import TiptapMention from '@tiptap/extension-mention'
|
||||
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||
import { NodeViewWrapper } from '@tiptap/react'
|
||||
import MentionList from './mention-list'
|
||||
import type { MentionListRef, MentionItem } from './mention-list'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useIcon } from '@/hooks/use-icon'
|
||||
import { useNodesDisplaySettings, type ItemType } from '@/stores/node-display-settings'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { GRAPH_COLORS } from '@/components/sketches/graph'
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import { useGraphControls } from '@/stores/graph-controls-store'
|
||||
|
||||
function hexWithOpacity(hex: string, opacity: number) {
|
||||
hex = hex.replace('#', '')
|
||||
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split('')
|
||||
.map((c) => c + c)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const alpha = Math.round(opacity * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
.toUpperCase()
|
||||
|
||||
return `#${hex}${alpha}`
|
||||
}
|
||||
|
||||
const getMentionItemsFromNodes = (): MentionItem[] => {
|
||||
const nodes = useGraphStore.getState().nodes
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const label = node.data?.label || node.data?.username || node.id
|
||||
const type = node.data?.type as ItemType
|
||||
const nodeId = node.data?.id || node.id
|
||||
|
||||
if (!type) return null
|
||||
|
||||
return {
|
||||
value: label,
|
||||
type: type,
|
||||
nodeId: nodeId
|
||||
}
|
||||
})
|
||||
.filter((item): item is MentionItem => item !== null)
|
||||
}
|
||||
|
||||
const updatePosition = (editor: Editor, element: HTMLElement) => {
|
||||
const virtualElement = {
|
||||
getBoundingClientRect: () =>
|
||||
posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to)
|
||||
}
|
||||
|
||||
computePosition(virtualElement, element, {
|
||||
placement: 'bottom-start',
|
||||
strategy: 'absolute',
|
||||
middleware: [shift(), flip()]
|
||||
}).then(({ x, y, strategy }) => {
|
||||
element.style.width = 'max-content'
|
||||
element.style.position = strategy
|
||||
element.style.left = `${x}px`
|
||||
element.style.top = `${y}px`
|
||||
})
|
||||
}
|
||||
|
||||
// Composant React custom pour le rendu de la mention
|
||||
const MentionComponent = memo((props: any) => {
|
||||
const nodeId = props.node.attrs.nodeId
|
||||
const type = props.node.attrs.type as ItemType | null
|
||||
const Icon = type ? (useIcon(type, null) ?? null) : null
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
const color = type ? (colors[type] ?? GRAPH_COLORS.NODE_DEFAULT) : GRAPH_COLORS.NODE_DEFAULT
|
||||
const centerOnNode = useGraphControls((state) => state.centerOnNode)
|
||||
const setCurrentNodeFromId = useGraphStore((state) => state.setCurrentNodeFromId)
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const node = setCurrentNodeFromId(nodeId)
|
||||
if (node) {
|
||||
const { x, y } = node
|
||||
// Auto-zoom if enabled and node has coordinates
|
||||
if (x !== undefined && y !== undefined) {
|
||||
setTimeout(() => {
|
||||
centerOnNode(x, y)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
}, [nodeId, centerOnNode])
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
style={{ display: 'inline-flex', justifyItems: 'center', padding: 0, margin: 0 }}
|
||||
>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
onClick={handleClick}
|
||||
className="h-5 px-.5 gap-1 cursor-pointer items-center text-foreground"
|
||||
style={{
|
||||
backgroundColor: hexWithOpacity(color, 0.2),
|
||||
//@ts-ignore
|
||||
border: `solid 1px ${hexWithOpacity(color, 0.5)}`
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon size={19} iconOnly className="opacity-60" style={{ color }} />}
|
||||
{props.node.attrs.label}
|
||||
</Button>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
})
|
||||
|
||||
export const Mention = TiptapMention.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MentionComponent)
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-label'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.label) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'data-label': attributes.label
|
||||
}
|
||||
}
|
||||
},
|
||||
type: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-type'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.type) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'data-type': attributes.type
|
||||
}
|
||||
}
|
||||
},
|
||||
nodeId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-node-id'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.nodeId) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
'data-node-id': attributes.nodeId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const children = []
|
||||
children.push([
|
||||
'span',
|
||||
{ class: 'mention-label' },
|
||||
`${this.options.suggestion.char}${node.attrs.label}`
|
||||
])
|
||||
return ['span', mergeAttributes({ class: 'mention' }, HTMLAttributes), ...children]
|
||||
}
|
||||
}).configure({
|
||||
deleteTriggerWithBackspace: true,
|
||||
suggestion: {
|
||||
char: '@',
|
||||
items: ({ query }: { query: string }) => {
|
||||
const items = getMentionItemsFromNodes()
|
||||
return items
|
||||
.filter((item) => item.value.toLowerCase().includes(query.toLowerCase()))
|
||||
.slice(0, 10)
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<MentionListRef> | undefined
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor
|
||||
})
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
const element = component.element as HTMLElement
|
||||
element.style.position = 'absolute'
|
||||
element.style.zIndex = '9999'
|
||||
document.body.appendChild(element)
|
||||
updatePosition(props.editor, element)
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps) {
|
||||
if (!component) return
|
||||
component.updateProps(props)
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
const element = component.element as HTMLElement
|
||||
updatePosition(props.editor, element)
|
||||
},
|
||||
|
||||
onKeyDown(props: { event: KeyboardEvent }) {
|
||||
if (props.event.key === 'Escape') {
|
||||
component?.destroy()
|
||||
return true
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown(props) ?? false
|
||||
},
|
||||
|
||||
onExit() {
|
||||
if (!component) return
|
||||
component.element.remove()
|
||||
component.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
} as Partial<SuggestionOptions>
|
||||
})
|
||||
|
||||
export default Mention
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
Color,
|
||||
UnsetAllMarks,
|
||||
ResetMarksOnEnter,
|
||||
FileHandler
|
||||
FileHandler,
|
||||
Mention
|
||||
} from '../extensions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { fileToBase64, getOutput, randomId } from '../utils'
|
||||
@@ -166,6 +167,7 @@ const createExtensions = ({
|
||||
HorizontalRule,
|
||||
ResetMarksOnEnter,
|
||||
CodeBlockLowlight,
|
||||
Mention,
|
||||
Placeholder.configure({ placeholder: () => placeholder })
|
||||
]
|
||||
|
||||
|
||||
86
flowsint-app/src/components/shared/save-status-badge.tsx
Normal file
86
flowsint-app/src/components/shared/save-status-badge.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { memo } from 'react'
|
||||
import { Cloud, CloudOff, Loader2, Check, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
|
||||
export type SaveStatusType = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'unsaved'
|
||||
|
||||
interface SaveStatusBadgeProps {
|
||||
status: SaveStatusType
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SaveStatusBadge = memo(({ status, className }: SaveStatusBadgeProps) => {
|
||||
const getStatusConfig = () => {
|
||||
// Normalize 'unsaved' to 'pending' for display
|
||||
const normalizedStatus = status === 'unsaved' ? 'pending' : status
|
||||
|
||||
switch (normalizedStatus) {
|
||||
case 'idle':
|
||||
return {
|
||||
icon: Cloud,
|
||||
text: 'All changes saved',
|
||||
className: 'text-muted-foreground/60 bg-muted/30 border-muted'
|
||||
}
|
||||
case 'pending':
|
||||
return {
|
||||
icon: Cloud,
|
||||
text: 'Pending...',
|
||||
className: 'text-muted-foreground bg-muted/50 border-muted'
|
||||
}
|
||||
case 'saving':
|
||||
return {
|
||||
icon: Loader2,
|
||||
text: 'Saving...',
|
||||
className:
|
||||
'text-blue-600 bg-blue-50 dark:bg-blue-950/30 dark:text-blue-400 border-blue-200 dark:border-blue-800',
|
||||
iconClassName: 'animate-spin'
|
||||
}
|
||||
case 'saved':
|
||||
return {
|
||||
icon: Check,
|
||||
text: 'Saved',
|
||||
className:
|
||||
'text-green-600 bg-green-50 dark:bg-green-950/30 dark:text-green-400 border-green-200 dark:border-green-800'
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
text: 'Error saving',
|
||||
className:
|
||||
'text-red-600 bg-red-50 dark:bg-red-950/30 dark:text-red-400 border-red-200 dark:border-red-800'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
icon: CloudOff,
|
||||
text: 'Unknown',
|
||||
className: 'text-muted-foreground bg-muted/50 border-muted'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = getStatusConfig()
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center h-8 gap-1.5 px-1.5 py-1.5',
|
||||
'rounded-md !bg-transparent',
|
||||
'text-xs font-medium',
|
||||
'transition-all duration-200',
|
||||
config.className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-3.5 w-3.5', config.iconClassName)} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{config.text}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
|
||||
SaveStatusBadge.displayName = 'SaveStatusBadge'
|
||||
@@ -56,7 +56,7 @@ export const useGraphInitialization = ({
|
||||
if (graphRef.current && typeof graphRef.current.centerAt === 'function') {
|
||||
graphRef.current.centerAt(x, y, 400)
|
||||
if (typeof graphRef.current.zoom === 'function') {
|
||||
graphRef.current.zoom(6, 400)
|
||||
graphRef.current.zoom(12, 400)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -78,12 +78,12 @@ export const useGraphInitialization = ({
|
||||
|
||||
return () => {
|
||||
setActions({
|
||||
zoomIn: () => { },
|
||||
zoomOut: () => { },
|
||||
zoomToFit: () => { },
|
||||
zoomToSelection: () => { },
|
||||
centerOnNode: () => { },
|
||||
regenerateLayout: () => { },
|
||||
zoomIn: () => {},
|
||||
zoomOut: () => {},
|
||||
zoomToFit: () => {},
|
||||
zoomToSelection: () => {},
|
||||
centerOnNode: () => {},
|
||||
regenerateLayout: () => {},
|
||||
getViewportCenter: () => null
|
||||
})
|
||||
}
|
||||
@@ -95,10 +95,7 @@ export const useGraphInitialization = ({
|
||||
if (!graphInstance || isGraphReadyRef.current) return
|
||||
|
||||
// Wait for graph methods to be available
|
||||
if (
|
||||
typeof graphInstance.zoom !== 'function' ||
|
||||
typeof graphInstance.zoomToFit !== 'function'
|
||||
) {
|
||||
if (typeof graphInstance.zoom !== 'function' || typeof graphInstance.zoomToFit !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +1,11 @@
|
||||
import { memo } from 'react'
|
||||
import { Cloud, CloudOff, Loader2, Check, AlertCircle } from 'lucide-react'
|
||||
import { SaveStatus } from '@/hooks/use-save-node-positions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { useGraphSaveStatus } from '@/stores/graph-save-status-store'
|
||||
import { SaveStatusBadge } from '@/components/shared/save-status-badge'
|
||||
|
||||
interface SaveStatusIndicatorProps {
|
||||
status: SaveStatus
|
||||
}
|
||||
export const SaveStatusIndicator = memo(() => {
|
||||
const status = useGraphSaveStatus((s) => s.saveStatus)
|
||||
|
||||
export const SaveStatusIndicator = memo(({ status }: SaveStatusIndicatorProps) => {
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return {
|
||||
icon: Cloud,
|
||||
text: 'All changes saved',
|
||||
className: 'text-muted-foreground/60 bg-muted/30 border-muted'
|
||||
}
|
||||
case 'pending':
|
||||
return {
|
||||
icon: Cloud,
|
||||
text: 'Pending...',
|
||||
className: 'text-muted-foreground bg-muted/50 border-muted'
|
||||
}
|
||||
case 'saving':
|
||||
return {
|
||||
icon: Loader2,
|
||||
text: 'Saving...',
|
||||
className: 'text-blue-600 bg-blue-50 dark:bg-blue-950/30 dark:text-blue-400 border-blue-200 dark:border-blue-800',
|
||||
iconClassName: 'animate-spin'
|
||||
}
|
||||
case 'saved':
|
||||
return {
|
||||
icon: Check,
|
||||
text: 'Saved',
|
||||
className: 'text-green-600 bg-green-50 dark:bg-green-950/30 dark:text-green-400 border-green-200 dark:border-green-800'
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
text: 'Error saving',
|
||||
className: 'text-red-600 bg-red-50 dark:bg-red-950/30 dark:text-red-400 border-red-200 dark:border-red-800'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
icon: CloudOff,
|
||||
text: 'Unknown',
|
||||
className: 'text-muted-foreground bg-muted/50 border-muted'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const config = getStatusConfig()
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center h-8 gap-1.5 px-2.5 py-1.5',
|
||||
'rounded-md border',
|
||||
'text-xs font-medium',
|
||||
'transition-all duration-200',
|
||||
config.className
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-3.5 w-3.5', config.iconClassName)} />
|
||||
{/* <span>{config.text}</span> */}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{config.text}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
return <SaveStatusBadge status={status} />
|
||||
})
|
||||
|
||||
SaveStatusIndicator.displayName = 'SaveStatusIndicator'
|
||||
|
||||
@@ -55,6 +55,7 @@ interface GraphState {
|
||||
// === Action Type for Form ===
|
||||
currentNodeType: ActionItem | null
|
||||
setCurrentNodeType: (nodeType: ActionItem | null) => void
|
||||
setCurrentNodeFromId: (nodeId: string) => GraphNode | null
|
||||
handleOpenFormModal: (selectedItem: ActionItem | undefined) => void
|
||||
|
||||
// === Action Type for Edit form ===
|
||||
@@ -232,6 +233,18 @@ export const useGraphStore = create<GraphState>()(
|
||||
set({ currentNode: node })
|
||||
}
|
||||
},
|
||||
setCurrentNodeFromId: (nodeId) => {
|
||||
const { currentNode, nodes } = get()
|
||||
// Only update if the node is actually different
|
||||
if (currentNode?.id !== nodeId) {
|
||||
const node = nodes.find((n) => n.id === nodeId)
|
||||
if (node) {
|
||||
set({ currentNode: node })
|
||||
return node
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
setCurrentEdge: (edge) => {
|
||||
const { currentEdge } = get()
|
||||
// Only update if the edge is actually different
|
||||
|
||||
Reference in New Issue
Block a user