import { Button, Dialog } from '@/components/ui' import Editor, { type Monaco } from '@monaco-editor/react' import type * as monacoApi from 'monaco-editor' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FaArrowDown, FaArrowUp, FaBook, FaCheck, FaChevronDown, FaChevronRight, FaCode, FaCopy, FaExclamationTriangle, FaMagic, FaPlus, FaSearch, FaTimes, FaTrash, } from 'react-icons/fa' import { apiEntryName, attachScriptIntelliSense, insertAtCursor } from './scriptIntelliSense' import { scriptRuleConditions, type ScriptDialect, type ScriptRecipeParam, type ScriptRule, type ScriptRuleCondition, } from './types' export type ScriptBuilderDialogProps = { isOpen: boolean value?: string dialect: ScriptDialect onClose: () => void onApply: (value: string) => void /** Kaydet/Vazgeç etiketleri; çağıran kendi çevirisini geçebilir. */ saveLabel?: string cancelLabel?: string } const controlClass = 'w-full min-w-0 h-9 px-2 rounded border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-100 focus:outline-none focus:border-indigo-400' const cardClass = 'rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900' const editorOptions = { automaticLayout: true, fontSize: 12, minimap: { enabled: false }, scrollBeyondLastLine: false, tabSize: 2, wordWrap: 'on', quickSuggestions: { other: true, comments: false, strings: true }, suggestOnTriggerCharacters: true, padding: { top: 8, bottom: 8 }, } as const const emptyCondition = (kind?: string): ScriptRuleCondition => ({ operator: 'equals', source: '', value: '', ...(kind ? { kind } : {}), }) function ScriptBuilderDialog({ isOpen, value, dialect, onClose, onApply, saveLabel = 'Kaydet', cancelLabel = 'Vazgeç', }: ScriptBuilderDialogProps) { const [rules, setRules] = useState([]) const [code, setCode] = useState('') // Sihirbaz bir butona basılarak açılır; varsayılan her zaman kod editörüdür. const [view, setView] = useState<'code' | 'wizard'>('code') const [openRules, setOpenRules] = useState>({}) const [paletteOpen, setPaletteOpen] = useState(true) const [helpOpen, setHelpOpen] = useState(true) const [helpSearch, setHelpSearch] = useState('') const [copied, setCopied] = useState(false) const editorRef = useRef(null) const disposeIntelliSense = useRef<(() => void) | null>(null) /** Sihirbazda ilk düzenleme yapılana kadar kod'a dokunulmaz. */ const wizardTouched = useRef(false) /** * Lehçe nesnesi çağıranın prop'larından türetildiği için her render'da yeniden * oluşabilir. Sıfırlama efektinin ona bağlanması, kullanıcı yazarken editörü * boşaltırdı — o yüzden efekt lehçeyi ref üzerinden okur. */ const dialectRef = useRef(dialect) dialectRef.current = dialect const generatedScript = useMemo(() => dialect.buildScript(rules), [dialect, rules]) const completeRuleCount = rules.filter((rule) => dialect.isRuleComplete(rule)).length const codeDiverged = code.trim() !== '' && code.trim() !== generatedScript.trim() useEffect(() => { if (!isOpen) return const parsed = dialectRef.current.parseScript(value) setRules(parsed.rules) setCode(value?.trim() ?? '') setOpenRules(Object.fromEntries(parsed.rules.map((rule) => [rule.id, false]))) setPaletteOpen(parsed.rules.length === 0) setView('code') setCopied(false) setHelpSearch('') wizardTouched.current = false }, [isOpen, value]) // Kural eklemek, çıkarmak veya değiştirmek script'e anında yansır: sihirbazda // yapılan her düzenleme kod editörünü de günceller. useEffect(() => { if (view !== 'wizard' || !wizardTouched.current) return setCode(generatedScript) }, [generatedScript, view]) useEffect( () => () => { disposeIntelliSense.current?.() disposeIntelliSense.current = null }, [], ) const optionSources = useMemo(() => { const sources = new Set() dialect.conditionKinds.forEach((kind) => kind.optionSource && sources.add(kind.optionSource)) dialect.recipes.forEach((recipe) => recipe.params.forEach((param) => param.optionSource && sources.add(param.optionSource)), ) return [...sources] }, [dialect]) const handleEditorMount = useCallback( (editor: monacoApi.editor.IStandaloneCodeEditor, monaco: Monaco) => { editorRef.current = editor disposeIntelliSense.current?.() disposeIntelliSense.current = attachScriptIntelliSense( monaco, editor, () => dialectRef.current, { optionSources }, ) }, [optionSources], ) const touchRules = (next: (current: ScriptRule[]) => ScriptRule[]) => { wizardTouched.current = true setRules(next) } const updateRule = (id: string, patch: Partial) => touchRules((current) => current.map((rule) => (rule.id === id ? { ...rule, ...patch } : rule))) const updateParam = (id: string, key: string, next: string) => touchRules((current) => current.map((rule) => rule.id === id ? { ...rule, params: { ...rule.params, [key]: next } } : rule, ), ) /** * Koşul güncellemesi: `index` 0 ise ana koşul, değilse ek koşullardan biri. * Ek koşul kalmadığında `conditions` alanı tamamen kaldırılır — eski tek * koşullu kurallarla aynı JSON'a serileşmesi buna bağlı. */ const updateCondition = (rule: ScriptRule, index: number, patch: Partial) => { if (index === 0) { updateRule(rule.id, { condition: { ...rule.condition, ...patch } }) return } const conditions = [...(rule.conditions ?? [])] conditions[index - 1] = { ...conditions[index - 1], ...patch } updateRule(rule.id, { conditions }) } const addCondition = (rule: ScriptRule) => updateRule(rule.id, { conditions: [ ...(rule.conditions ?? []), // Tek kaynak türü olan lehçelerde `kind` taşımak gereksiz gürültü. emptyCondition(dialect.conditionKinds.length > 1 ? rule.condition.kind : undefined), ], join: rule.join ?? 'and', }) const removeCondition = (rule: ScriptRule, index: number) => { const conditions = (rule.conditions ?? []).filter((_, item) => item !== index - 1) updateRule(rule.id, conditions.length ? { conditions } : { conditions: undefined, join: undefined }) } const moveRule = (index: number, delta: number) => touchRules((current) => { const target = index + delta if (target < 0 || target >= current.length) return current const next = [...current] const [moved] = next.splice(index, 1) next.splice(target, 0, moved) return next }) const addRule = (recipeId: string) => { const rule = dialect.createRule(recipeId) touchRules((current) => [...current, rule]) setOpenRules((current) => ({ ...current, [rule.id]: true })) } const copyScript = () => { navigator.clipboard?.writeText(view === 'wizard' ? generatedScript : code) setCopied(true) setTimeout(() => setCopied(false), 1500) } const openWizard = () => { // Elle yazılmış bir script'i kurallar okuyamıyorsa, kullanıcı bir kural // değiştirene kadar kod olduğu gibi durur; banner bunu söyler. const parsed = dialect.parseScript(code) if (parsed.rules.length) { setRules(parsed.rules) setOpenRules(Object.fromEntries(parsed.rules.map((rule) => [rule.id, false]))) } setView('wizard') } const filteredApi = useMemo(() => { const query = helpSearch.trim().toLocaleLowerCase('tr') if (!query) return dialect.apiReference return dialect.apiReference.filter((entry) => `${entry.signature} ${entry.description}`.toLocaleLowerCase('tr').includes(query), ) }, [dialect.apiReference, helpSearch]) const filteredSnippets = useMemo(() => { const query = helpSearch.trim().toLocaleLowerCase('tr') if (!query) return dialect.snippets return dialect.snippets.filter((snippet) => `${snippet.label} ${snippet.group} ${snippet.description} ${snippet.code}` .toLocaleLowerCase('tr') .includes(query), ) }, [dialect.snippets, helpSearch]) const renderOptionInput = ( optionSource: string | undefined, selectedValue: string, onChange: (next: string) => void, placeholder: string, ) => { const options = optionSource ? dialect.getOptions(optionSource) : [] if (!options.length) { return ( onChange(event.target.value)} /> ) } return ( ) } const renderParam = (rule: ScriptRule, param: ScriptRecipeParam) => { if (param.type === 'fieldList') { const selectedFields = rule.fields ?? [] const options = dialect.getOptions(param.optionSource || '') return (
{param.label}
{selectedFields.map((fieldName) => ( {fieldName} ))}
) } const currentValue = rule.params[param.key] ?? '' return ( ) } const renderCondition = (rule: ScriptRule, condition: ScriptRuleCondition, index: number) => { const operator = dialect.operators.find((item) => item.value === condition.operator) const kind = dialect.conditionKinds.find((item) => item.value === condition.kind) || dialect.conditionKinds[0] return (
{index > 0 ? ( ) : (
Koşul
EĞER
)} {operator?.needsSource && dialect.conditionKinds.length > 1 && ( )} {operator?.needsSource && ( )} {operator?.needsValue && ( )} {index > 0 && (
)}
) } const renderRule = (rule: ScriptRule, index: number) => { const recipe = dialect.getRecipe(rule.recipe) if (!recipe) return null const open = openRules[rule.id] ?? true const complete = dialect.isRuleComplete(rule) const group = dialect.groups.find((item) => item.key === recipe.group) const Icon = group?.icon ?? FaCode const conditions = scriptRuleConditions(rule) const trigger = dialect.getTrigger?.(rule) const triggerInfo = dialect.triggers?.find((item) => item.value === trigger) return (
{index + 1}. {recipe.label}
{dialect.describeRule(rule)}
{conditions.length > 1 && ( {conditions.length} koşul · {rule.join === 'or' ? 'VEYA' : 'VE'} )} {triggerInfo && trigger !== dialect.triggers?.[0]?.value && ( {triggerInfo.label} )} {!complete && ( eksik )}
{open && (

{recipe.summary}

{dialect.triggers && ( )} {conditions.map((condition, conditionIndex) => renderCondition(rule, condition, conditionIndex), )}
{recipe.params.map((param) => renderParam(rule, param))}
              {dialect.buildRuleLine(rule, rules) || recipe.example}
            
)}
) } const helpPanel = (
setHelpSearch(event.target.value)} />
Snippet
{filteredSnippets.map((snippet) => ( ))} {!filteredSnippets.length && ( Eşleşen snippet yok. )}
Runtime API
{filteredApi.map((entry) => ( ))} {!filteredApi.length && ( Eşleşen kısayol yok. )}
) return ( {/* Yükseklik zinciri iki yerden kopuyordu: Dialog'un `height` prop'u yalnızca react-modal içeriğine uygulanıyor, ve `.dialog-body` global olarak `flex-1` alıyor. `!flex-none` ile flex boyutlandırmasını kapatıp yüksekliği doğrudan veriyoruz. */}
{dialect.title}
{dialect.subject} {view === 'wizard' && ( {completeRuleCount}/{rules.length} kural )}
{view === 'code' && !helpOpen && ( )}
{view === 'wizard' && codeDiverged && (
Koddaki script kurallardan farklı. Burada bir kuralı değiştirdiğin anda kod kurallardan yeniden üretilir.
)} {view === 'code' ? (
{dialect.codeHint} {rules.length > 0 && ( )}
setCode(next || '')} />
{helpOpen && helpPanel}
) : (
{paletteOpen && (
{dialect.groups.map((group) => { const Icon = group.icon const groupRecipes = dialect.recipes.filter( (recipe) => recipe.group === group.key, ) if (!groupRecipes.length) return null return (
{group.title}
{groupRecipes.map((recipe) => ( ))}
) })}
)}
{rules.map(renderRule)} {!rules.length && (
Henüz kural yok. Yukarıdan bir kural ekle; her kural tek satırlık script üretir ve kod sekmesine anında yansır.
)}
Script Önizleme {generatedScript ? `${generatedScript.split('\n').length - 1} satır` : 'boş'}

İlk satırdaki {'// @builder'} yorumu kuralları saklar; dialog yeniden açıldığında sihirbaz kuralları buradan geri yükler.

)}
) } export default ScriptBuilderDialog