sozsoft-platform/ui/src/components/scriptBuilder/ScriptBuilderDialog.tsx

925 lines
34 KiB
TypeScript
Raw Normal View History

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<ScriptRule[]>([])
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<Record<string, boolean>>({})
const [paletteOpen, setPaletteOpen] = useState(true)
const [helpOpen, setHelpOpen] = useState(true)
const [helpSearch, setHelpSearch] = useState('')
const [copied, setCopied] = useState(false)
const editorRef = useRef<monacoApi.editor.IStandaloneCodeEditor | null>(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<string>()
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<ScriptRule>) =>
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<ScriptRuleCondition>) => {
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 (
<input
className={controlClass}
placeholder={placeholder}
value={selectedValue}
onChange={(event) => onChange(event.target.value)}
/>
)
}
return (
<select
className={controlClass}
value={options.includes(selectedValue) ? selectedValue : ''}
onChange={(event) => onChange(event.target.value)}
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)
}
const renderParam = (rule: ScriptRule, param: ScriptRecipeParam) => {
if (param.type === 'fieldList') {
const selectedFields = rule.fields ?? []
const options = dialect.getOptions(param.optionSource || '')
return (
<div key={param.key} className="col-span-12 min-w-0">
<span className="mb-1 block text-xs text-gray-500">{param.label}</span>
<div className="flex flex-wrap items-center gap-1.5">
{selectedFields.map((fieldName) => (
<span
key={fieldName}
className="flex items-center gap-1 rounded-full bg-indigo-100 px-2 py-[2px] text-[11px] text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200"
>
{fieldName}
<button
type="button"
className="text-indigo-400 hover:text-red-500"
onClick={() =>
updateRule(rule.id, {
fields: selectedFields.filter((item) => item !== fieldName),
})
}
>
<FaTimes size={9} />
</button>
</span>
))}
<select
className={`${controlClass} !h-8 !w-44`}
value=""
onChange={(event) => {
const next = event.target.value
if (!next || selectedFields.includes(next)) return
updateRule(rule.id, { fields: [...selectedFields, next] })
}}
>
<option value="">+ ekle</option>
{options
.filter((fieldName) => !selectedFields.includes(fieldName))
.map((fieldName) => (
<option key={fieldName} value={fieldName}>
{fieldName}
</option>
))}
</select>
</div>
</div>
)
}
const currentValue = rule.params[param.key] ?? ''
return (
<label key={param.key} className="col-span-12 min-w-0 md:col-span-4">
<span className="mb-1 block truncate text-xs text-gray-500" title={param.help}>
{param.label}
{param.optional && <span className="ml-1 text-gray-300">(ops.)</span>}
</span>
{param.type === 'option' &&
renderOptionInput(
param.optionSource,
currentValue,
(next) => updateParam(rule.id, param.key, next),
param.placeholder || 'Seç',
)}
{param.type === 'select' && (
<select
className={controlClass}
value={currentValue}
onChange={(event) => updateParam(rule.id, param.key, event.target.value)}
>
{param.choices?.map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
</option>
))}
</select>
)}
{(param.type === 'text' || param.type === 'number') && (
<input
className={controlClass}
type={param.type === 'number' ? 'number' : 'text'}
value={currentValue}
placeholder={param.placeholder}
onChange={(event) => updateParam(rule.id, param.key, event.target.value)}
/>
)}
{param.help && (
<span className="mt-1 block truncate text-[11px] text-gray-400" title={param.help}>
{param.help}
</span>
)}
</label>
)
}
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 (
<div key={index} className="grid grid-cols-12 items-end gap-2">
{index > 0 ? (
<label className="col-span-12 min-w-0 md:col-span-2">
<span className="mb-1 block text-xs text-gray-500">Bağlaç</span>
<select
className={controlClass}
value={rule.join ?? 'and'}
onChange={(event) =>
updateRule(rule.id, { join: event.target.value as ScriptRule['join'] })
}
>
<option value="and">VE</option>
<option value="or">VEYA</option>
</select>
</label>
) : (
<div className="col-span-12 hidden md:col-span-2 md:block">
<span className="mb-1 block text-xs text-gray-500">Koşul</span>
<div className="flex h-9 items-center text-xs font-semibold text-gray-400">EĞER</div>
</div>
)}
<label className="col-span-12 min-w-0 md:col-span-3">
<span className="mb-1 block text-xs text-gray-500">Karşılaştırma</span>
<select
className={controlClass}
value={condition.operator}
onChange={(event) => updateCondition(rule, index, { operator: event.target.value })}
>
{dialect.operators.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
{operator?.needsSource && dialect.conditionKinds.length > 1 && (
<label className="col-span-12 min-w-0 md:col-span-2">
<span className="mb-1 block text-xs text-gray-500">Kaynak</span>
<select
className={controlClass}
value={condition.kind ?? dialect.conditionKinds[0]?.value ?? ''}
onChange={(event) =>
updateCondition(rule, index, { kind: event.target.value, source: '' })
}
>
{dialect.conditionKinds.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
)}
{operator?.needsSource && (
<label className="col-span-12 min-w-0 md:col-span-3">
<span className="mb-1 block text-xs text-gray-500">Alan</span>
{renderOptionInput(
kind?.optionSource,
condition.source,
(next) => updateCondition(rule, index, { source: next }),
kind?.placeholder || 'Seç',
)}
</label>
)}
{operator?.needsValue && (
<label className="col-span-12 min-w-0 md:col-span-2">
<span className="mb-1 block text-xs text-gray-500">Değer</span>
<input
className={controlClass}
value={condition.value}
placeholder="Aktif / 1000"
onChange={(event) => updateCondition(rule, index, { value: event.target.value })}
/>
</label>
)}
{index > 0 && (
<div className="col-span-12 flex md:col-span-1">
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Koşulu kaldır"
icon={<FaTimes />}
onClick={() => removeCondition(rule, index)}
/>
</div>
)}
</div>
)
}
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 (
<div key={rule.id} className={cardClass}>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
className="shrink-0 text-gray-400"
onClick={() => setOpenRules((current) => ({ ...current, [rule.id]: !open }))}
>
{open ? <FaChevronDown size={11} /> : <FaChevronRight size={11} />}
</button>
<Icon className={complete ? 'text-indigo-500' : 'text-gray-300'} size={13} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">
{index + 1}. {recipe.label}
</div>
<div className="truncate text-[11px] text-gray-400" title={dialect.describeRule(rule)}>
{dialect.describeRule(rule)}
</div>
</div>
{conditions.length > 1 && (
<span className="shrink-0 rounded-full bg-violet-100 px-2 py-[1px] text-[11px] text-violet-700 dark:bg-violet-900 dark:text-violet-200">
{conditions.length} koşul · {rule.join === 'or' ? 'VEYA' : 'VE'}
</span>
)}
{triggerInfo && trigger !== dialect.triggers?.[0]?.value && (
<span
className="shrink-0 rounded-full bg-sky-100 px-2 py-[1px] text-[11px] text-sky-700 dark:bg-sky-900 dark:text-sky-200"
title={triggerInfo.help}
>
{triggerInfo.label}
</span>
)}
{!complete && (
<span
className="flex shrink-0 items-center gap-1 rounded-full bg-amber-100 px-2 py-[1px] text-[11px] text-amber-700 dark:bg-amber-900 dark:text-amber-200"
title={
dialect.getRuleWarning?.(rule) ||
'Zorunlu alanlar dolmadığı için bu kural script’e eklenmez.'
}
>
<FaExclamationTriangle size={9} /> eksik
</span>
)}
<div className="flex shrink-0 items-center">
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Yukarı taşı"
icon={<FaArrowUp />}
onClick={() => moveRule(index, -1)}
/>
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Aşağı taşı"
icon={<FaArrowDown />}
onClick={() => moveRule(index, 1)}
/>
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Kuralı sil"
icon={<FaTrash />}
onClick={() => touchRules((current) => current.filter((item) => item.id !== rule.id))}
/>
</div>
</div>
{open && (
<div className="border-t border-gray-100 px-3 py-3 dark:border-gray-800">
<p className="mb-3 text-[11px] text-gray-400">{recipe.summary}</p>
<div className="mb-3 flex flex-col gap-2 rounded-md bg-gray-50 p-2 dark:bg-gray-800/40">
{dialect.triggers && (
<label className="w-full md:w-56">
<span className="mb-1 block text-xs text-gray-500">Ne zaman</span>
<select
className={controlClass}
value={trigger ?? dialect.triggers[0].value}
onChange={(event) => updateRule(rule.id, { trigger: event.target.value })}
>
{dialect.triggers.map((item) => (
<option key={item.value} value={item.value} title={item.help}>
{item.label}
</option>
))}
</select>
</label>
)}
{conditions.map((condition, conditionIndex) =>
renderCondition(rule, condition, conditionIndex),
)}
<div>
<Button
size="xs"
type="button"
icon={<FaPlus />}
title="Aynı kurala ikinci bir koşul ekler; VE/VEYA ile birleşir."
onClick={() => addCondition(rule)}
>
Koşul ekle
</Button>
</div>
</div>
<div className="grid grid-cols-12 gap-x-3 gap-y-3">
{recipe.params.map((param) => renderParam(rule, param))}
</div>
<pre className="mt-3 overflow-x-auto rounded border border-gray-100 bg-gray-950 px-3 py-2 font-mono text-[11px] text-emerald-100 dark:border-gray-800">
{dialect.buildRuleLine(rule, rules) || recipe.example}
</pre>
</div>
)}
</div>
)
}
const helpPanel = (
<section className={`flex min-h-0 w-80 shrink-0 flex-col ${cardClass} p-3`}>
<div className="mb-2 flex items-center gap-2">
<FaSearch className="text-gray-400" size={11} />
<input
className="w-full min-w-0 bg-transparent text-xs text-gray-700 outline-none dark:text-gray-100"
placeholder="Kısayol veya snippet ara"
value={helpSearch}
onChange={(event) => setHelpSearch(event.target.value)}
/>
<Button
shape="circle"
size="xs"
type="button"
variant="plain"
title="Paneli kapat"
icon={<FaTimes />}
onClick={() => setHelpOpen(false)}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400">
Snippet
</div>
<div className="mb-3 flex flex-col gap-1">
{filteredSnippets.map((snippet) => (
<button
key={snippet.label}
type="button"
className="rounded border border-gray-100 px-2 py-1.5 text-left hover:border-indigo-300 hover:bg-indigo-50 dark:border-gray-800 dark:hover:bg-gray-800"
title={`${snippet.description}\n\n${snippet.code}`}
onClick={() => insertAtCursor(editorRef.current, snippet.code)}
>
<div className="truncate text-xs font-medium text-gray-700 dark:text-gray-100">
{snippet.label}
</div>
<div className="truncate text-[10px] text-gray-400">{snippet.description}</div>
</button>
))}
{!filteredSnippets.length && (
<span className="text-[11px] text-gray-400">Eşleşen snippet yok.</span>
)}
</div>
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400">
Runtime API
</div>
<div className="flex flex-col gap-1">
{filteredApi.map((entry) => (
<button
key={entry.signature}
type="button"
className="rounded px-1 py-1 text-left hover:bg-indigo-50 dark:hover:bg-gray-800"
title={entry.description}
onClick={() => insertAtCursor(editorRef.current, apiEntryName(entry))}
>
<code className="block truncate font-mono text-[11px] text-indigo-600 dark:text-indigo-300">
{entry.signature}
</code>
<span className="block truncate text-[10px] text-gray-400">{entry.description}</span>
</button>
))}
{!filteredApi.length && (
<span className="text-[11px] text-gray-400">Eşleşen kısayol yok.</span>
)}
</div>
</div>
</section>
)
return (
<Dialog
preventScroll
isOpen={isOpen}
width={1360}
contentClassName="flex flex-col"
style={{ overlay: { zIndex: 1300 } }}
onClose={onClose}
onRequestClose={onClose}
>
{/*
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.
*/}
<Dialog.Body className="flex h-[calc(100vh-16rem)] max-h-[calc(90vh-7rem)] min-h-[320px] !flex-none flex-col gap-3 !overflow-hidden mt-5">
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
<FaCode className="text-indigo-500" />
<h5 className="mr-1">{dialect.title}</h5>
<span
className="rounded-full bg-indigo-100 px-2.5 py-[2px] text-[11px] font-medium text-indigo-700 dark:bg-indigo-900 dark:text-indigo-200"
title={dialect.subjectHint}
>
{dialect.subject}
</span>
{view === 'wizard' && (
<span
className={`rounded-full px-2.5 py-[2px] text-[11px] font-medium ${
completeRuleCount
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-200'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
}`}
>
{completeRuleCount}/{rules.length} kural
</span>
)}
<div className="ml-auto flex items-center gap-2">
{view === 'code' && !helpOpen && (
<Button
size="sm"
type="button"
title="Kısayol ve snippet panelini aç"
icon={<FaBook />}
onClick={() => setHelpOpen(true)}
>
Yardım
</Button>
)}
<Button size="sm" type="button" icon={<FaCopy />} onClick={copyScript}>
{copied ? 'Kopyalandı' : 'Kopyala'}
</Button>
<div className="flex overflow-hidden rounded-md border border-gray-200 dark:border-gray-700">
<button
type="button"
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium ${
view === 'code'
? 'bg-indigo-500 text-white'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
onClick={() => setView('code')}
>
<FaCode /> Kod
</button>
<button
type="button"
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium ${
view === 'wizard'
? 'bg-indigo-500 text-white'
: 'text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title="Kod yazmadan kural kurmak için sihirbazı aç"
onClick={openWizard}
>
<FaMagic /> Sihirbaz
</button>
</div>
</div>
</div>
{view === 'wizard' && codeDiverged && (
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
<FaExclamationTriangle className="shrink-0" />
<span className="flex-1">
Koddaki script kurallardan farklı. Burada bir kuralı değiştirdiğin anda kod
kurallardan yeniden üretilir.
</span>
<Button size="xs" type="button" onClick={() => setView('code')}>
Kodu gör
</Button>
</div>
)}
{view === 'code' ? (
<div className="flex min-h-0 flex-1 gap-3">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center justify-between gap-2 text-xs text-gray-500">
<span className="min-w-0 truncate">{dialect.codeHint}</span>
{rules.length > 0 && (
<Button
disabled={!generatedScript}
size="xs"
type="button"
title="Sihirbazdaki kurallardan script'i yeniden üretir."
icon={<FaMagic />}
onClick={() => setCode(generatedScript)}
>
Kurallardan üret
</Button>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<Editor
height="100%"
language="javascript"
theme="vs-dark"
value={code}
options={editorOptions}
onMount={handleEditorMount}
onChange={(next) => setCode(next || '')}
/>
</div>
</div>
{helpOpen && helpPanel}
</div>
) : (
<div className="grid min-h-0 flex-1 grid-cols-12 gap-4 overflow-hidden">
<section className="col-span-7 flex min-h-0 flex-col gap-2 overflow-y-auto pr-1">
<div className={cardClass}>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
onClick={() => setPaletteOpen((current) => !current)}
>
<span className="shrink-0 text-gray-400">
{paletteOpen ? <FaChevronDown size={11} /> : <FaChevronRight size={11} />}
</span>
<FaPlus className="text-indigo-500" size={12} />
<span className="flex-1 text-sm font-semibold">Kural Ekle</span>
</button>
{paletteOpen && (
<div className="flex flex-col gap-3 border-t border-gray-100 px-3 py-3 dark:border-gray-800">
{dialect.groups.map((group) => {
const Icon = group.icon
const groupRecipes = dialect.recipes.filter(
(recipe) => recipe.group === group.key,
)
if (!groupRecipes.length) return null
return (
<div key={group.key}>
<div
className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-gray-400"
title={group.description}
>
<Icon size={10} /> {group.title}
</div>
<div className="flex flex-wrap gap-2">
{groupRecipes.map((recipe) => (
<Button
key={recipe.id}
size="sm"
type="button"
title={`${recipe.summary}\n\nÖrnek: ${recipe.example}`}
onClick={() => addRule(recipe.id)}
>
{recipe.label}
</Button>
))}
</div>
</div>
)
})}
</div>
)}
</div>
{rules.map(renderRule)}
{!rules.length && (
<div className="rounded-lg border border-dashed border-gray-300 p-8 text-center text-xs text-gray-400 dark:border-gray-700">
Henüz kural yok. Yukarıdan bir kural ekle; her kural tek satırlık script üretir ve
kod sekmesine anında yansır.
</div>
)}
</section>
<section className={`col-span-5 flex min-h-0 flex-col ${cardClass} p-3`}>
<div className="mb-3 flex items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm font-semibold">
<FaCode className="text-gray-400" />
Script Önizleme
</span>
<span className="rounded-full bg-gray-100 px-2 py-[2px] text-[11px] text-gray-500 dark:bg-gray-800 dark:text-gray-300">
{generatedScript ? `${generatedScript.split('\n').length - 1} satır` : 'boş'}
</span>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<Editor
height="100%"
language="javascript"
theme="vs-dark"
value={generatedScript || '// Kural ekledikçe script burada oluşur'}
options={{ ...editorOptions, readOnly: true }}
/>
</div>
<p className="mt-2 text-[11px] text-gray-400">
İlk satırdaki <code>{'// @builder'}</code> yorumu kuralları saklar; dialog yeniden
açıldığında sihirbaz kuralları buradan geri yükler.
</p>
</section>
</div>
)}
</Dialog.Body>
<Dialog.Footer className="mt-1 flex justify-end gap-2 border-t border-gray-200 pt-3 dark:border-gray-700">
<Button size="sm" type="button" variant="plain" icon={<FaTimes />} onClick={onClose}>
{cancelLabel}
</Button>
<Button
size="sm"
type="button"
variant="solid"
icon={<FaCheck />}
onClick={() => {
onApply(view === 'wizard' ? generatedScript : code)
onClose()
}}
>
{saveLabel}
</Button>
</Dialog.Footer>
</Dialog>
)
}
export default ScriptBuilderDialog