init: fuzzygraph app from fn_registry

This commit is contained in:
2026-04-06 00:56:50 +02:00
commit 23198eee0c
42 changed files with 5539 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
import { useState, useEffect } from 'react'
import type { Entity, EntityTypePreset, EntityInput } from '../types'
import { SimpleSelect } from '@fn_library'
import { X } from 'lucide-react'
interface Props {
presets: EntityTypePreset[]
entity: Entity | null // null = create, non-null = edit
onSubmit: (input: EntityInput) => void
onClose: () => void
}
export function EntityDialog({ presets, entity, onSubmit, onClose }: Props) {
const [name, setName] = useState(entity?.name ?? '')
const [typeRef, setTypeRef] = useState(entity?.type_ref ?? presets[0]?.type_ref ?? '')
const [description, setDescription] = useState(entity?.description ?? '')
const [notes, setNotes] = useState(entity?.notes ?? '')
const [tagsStr, setTagsStr] = useState((entity?.tags ?? []).join(', '))
const [metadata, setMetadata] = useState<Record<string, string>>(() => {
const m: Record<string, string> = {}
if (entity?.metadata) {
for (const [k, v] of Object.entries(entity.metadata)) {
m[k] = String(v ?? '')
}
}
return m
})
const currentPreset = presets.find(p => p.type_ref === typeRef)
const metadataFields = currentPreset?.metadata_fields ?? []
// When type changes, reset metadata fields to match new type
useEffect(() => {
if (!entity) {
const m: Record<string, string> = {}
for (const f of metadataFields) {
m[f] = metadata[f] ?? ''
}
setMetadata(m)
}
}, [typeRef]) // eslint-disable-line react-hooks/exhaustive-deps
const handleSubmit = () => {
const cleanMeta: Record<string, unknown> = {}
for (const [k, v] of Object.entries(metadata)) {
if (v.trim()) {
// Try parsing as number
const num = Number(v)
if (!isNaN(num) && v.trim() !== '') {
cleanMeta[k] = num
} else if (v === 'true') {
cleanMeta[k] = true
} else if (v === 'false') {
cleanMeta[k] = false
} else {
cleanMeta[k] = v.trim()
}
}
}
onSubmit({
name: name.trim(),
type_ref: typeRef,
description: description.trim(),
tags: tagsStr.split(',').map(t => t.trim()).filter(Boolean),
metadata: cleanMeta,
notes: notes.trim(),
})
}
const inputStyle = {
background: 'var(--input)',
color: 'var(--foreground)',
border: '1px solid var(--border)',
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: 'rgba(0,0,0,0.6)' }}>
<div className="w-[520px] max-h-[85vh] overflow-y-auto rounded-lg p-5" style={{ background: 'var(--card)', border: '1px solid var(--border)' }}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold">{entity ? 'Edit Entity' : 'New Entity'}</h3>
<button onClick={onClose} className="p-1"><X size={16} style={{ color: 'var(--muted-foreground)' }} /></button>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Name</label>
<input value={name} onChange={e => setName(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Type</label>
<SimpleSelect
value={typeRef}
onValueChange={setTypeRef}
options={presets.map(p => ({ value: p.type_ref, label: p.label }))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Description</label>
<input value={description} onChange={e => setDescription(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} />
</div>
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Tags (comma separated)</label>
<input value={tagsStr} onChange={e => setTagsStr(e.target.value)} className="w-full px-3 py-1.5 rounded text-sm" style={inputStyle} placeholder="osint, high-risk" />
</div>
{metadataFields.length > 0 && (
<div>
<label className="block text-xs mb-1 font-semibold" style={{ color: 'var(--muted-foreground)' }}>
Metadata ({currentPreset?.label})
</label>
<div className="space-y-2">
{metadataFields.map(field => (
<div key={field} className="flex items-center gap-2">
<span className="text-xs w-28 text-right" style={{ color: 'var(--muted-foreground)' }}>{field}</span>
<input
value={metadata[field] ?? ''}
onChange={e => setMetadata(prev => ({ ...prev, [field]: e.target.value }))}
className="flex-1 px-2 py-1 rounded text-sm"
style={inputStyle}
/>
</div>
))}
</div>
</div>
)}
<div>
<label className="block text-xs mb-1" style={{ color: 'var(--muted-foreground)' }}>Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={3}
className="w-full px-3 py-1.5 rounded text-sm resize-none"
style={inputStyle}
placeholder="Operational notes..."
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<button onClick={onClose} className="px-3 py-1.5 rounded text-sm" style={{ background: 'var(--secondary)', color: 'var(--secondary-foreground)' }}>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!name.trim()}
className="px-3 py-1.5 rounded text-sm font-medium disabled:opacity-40"
style={{ background: 'var(--primary)', color: 'var(--primary-foreground)' }}
>
{entity ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
)
}