1035 lines
39 KiB
TypeScript
1035 lines
39 KiB
TypeScript
import { Button, Dialog, Select } from '@/components/ui'
|
||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||
import Editor, { type Monaco } from '@monaco-editor/react'
|
||
import type * as monacoApi from 'monaco-editor'
|
||
import { Fragment, 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 Input from '@/components/ui/Input'
|
||
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-8 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,
|
||
cancelLabel,
|
||
}: ScriptBuilderDialogProps) {
|
||
const { translate } = useLocalization()
|
||
const saveText = saveLabel ?? translate('::App.Platform.Save')
|
||
const cancelText = cancelLabel ?? translate('::App.Platform.Cancel')
|
||
const [rules, setRules] = useState<ScriptRule[]>([])
|
||
const [code, setCode] = useState('')
|
||
const [syntaxError, setSyntaxError] = 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')
|
||
|
||
// Önceki kaydetme denemesinin hatası, metin değişince ya da diyalog yeniden açılınca geçersizdir.
|
||
useEffect(() => setSyntaxError(''), [code, view, isOpen])
|
||
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()
|
||
// Kural başlığı taşıyan ama elle değiştirilmiş kod: başlık artık gövdeyi anlatmaz. Kalırsa
|
||
// dialog ve sunucu (`EditorScript.Rebuild`) gövdeyi başlıktan yeniden üretip elle yapılan
|
||
// düzeltmeyi geri alır.
|
||
const staleHeader = useMemo(() => {
|
||
const parsed = dialect.parseScript(code)
|
||
return parsed.hasHeader && !parsed.inSync
|
||
}, [dialect, code])
|
||
|
||
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
|
||
unstyle
|
||
className={controlClass}
|
||
placeholder={placeholder}
|
||
value={selectedValue}
|
||
onChange={(event) => onChange(event.target.value)}
|
||
/>
|
||
)
|
||
}
|
||
return (
|
||
<Select
|
||
isClearable
|
||
size="sm"
|
||
className="w-full"
|
||
placeholder={placeholder}
|
||
menuPortalTarget={document.body}
|
||
options={options.map((option) => ({ value: option, label: option }))}
|
||
value={
|
||
options.includes(selectedValue) ? { value: selectedValue, label: selectedValue } : null
|
||
}
|
||
onChange={(option) => onChange(option?.value ?? '')}
|
||
/>
|
||
)
|
||
}
|
||
|
||
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="min-w-0 sm:col-span-2 xl:col-span-3">
|
||
{fieldLabel(param.label, param.help)}
|
||
<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
|
||
size="sm"
|
||
className="w-44"
|
||
placeholder={translate('::App.ScriptBuilder.AddOption')}
|
||
menuPortalTarget={document.body}
|
||
options={options
|
||
.filter((fieldName) => !selectedFields.includes(fieldName))
|
||
.map((fieldName) => ({ value: fieldName, label: fieldName }))}
|
||
value={null}
|
||
onChange={(option) => {
|
||
const next = option?.value
|
||
if (!next || selectedFields.includes(next)) return
|
||
updateRule(rule.id, { fields: [...selectedFields, next] })
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const currentValue = rule.params[param.key] ?? ''
|
||
const ruleChoices =
|
||
param.type === 'text' ? dialect.getParamChoices?.(rule, param.key) : undefined
|
||
const choices = ruleChoices ?? param.choices
|
||
const inputType = ruleChoices ? 'select' : param.type
|
||
|
||
return (
|
||
<label key={param.key} className="min-w-0">
|
||
<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">
|
||
({translate('::App.ScriptBuilder.Optional')})
|
||
</span>
|
||
)}
|
||
</span>
|
||
{param.type === 'option' &&
|
||
renderOptionInput(
|
||
param.optionSource,
|
||
currentValue,
|
||
(next) => updateParam(rule.id, param.key, next),
|
||
param.placeholder || translate('::App.ScriptBuilder.Choose'),
|
||
)}
|
||
{inputType === 'select' && (
|
||
<Select
|
||
size="sm"
|
||
className="w-full"
|
||
menuPortalTarget={document.body}
|
||
options={(choices ?? []).map((choice) => ({
|
||
value: choice.value,
|
||
label: choice.label,
|
||
}))}
|
||
value={(choices ?? [])
|
||
.filter((choice) => choice.value === currentValue)
|
||
.map((choice) => ({ value: choice.value, label: choice.label }))}
|
||
onChange={(option) => option && updateParam(rule.id, param.key, option.value)}
|
||
/>
|
||
)}
|
||
{(inputType === 'text' || inputType === 'number') && (
|
||
<Input
|
||
unstyle
|
||
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 joinOptions = [
|
||
{ value: 'and', label: translate('::App.Platform.And') },
|
||
{ value: 'or', label: translate('::App.Platform.Or') },
|
||
]
|
||
|
||
const operatorOptions = dialect.operators.map((item) => ({
|
||
value: item.value,
|
||
label: item.label.startsWith('App.') ? translate('::' + item.label) : item.label,
|
||
}))
|
||
|
||
const conditionKindOptions = dialect.conditionKinds.map((item) => ({
|
||
value: item.value,
|
||
label: item.label,
|
||
}))
|
||
|
||
const triggerOptions = (dialect.triggers ?? []).map((item) => ({
|
||
value: item.value,
|
||
label: item.label,
|
||
}))
|
||
|
||
/** Ayar satirindaki her alan ayni yukseklikte, dar panelde alt satira sarar. */
|
||
const fieldClass = 'min-w-0 basis-[9.5rem] grow'
|
||
|
||
const fieldLabel = (text: string, title?: string) => (
|
||
<span className="mb-1 block truncate text-xs text-gray-500" title={title}>
|
||
{text}
|
||
</span>
|
||
)
|
||
|
||
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]
|
||
|
||
// Kosul parcalari tek bir sarmalayici yerine dogrudan ust flex satirina
|
||
// giriyor; boylece tetikleyici ve kosullar yan yana dizilip birlikte sarar.
|
||
return (
|
||
<Fragment key={index}>
|
||
{index > 0 ? (
|
||
<label className="min-w-0 basis-[6.5rem] grow-0">
|
||
{fieldLabel(translate('::App.ScriptBuilder.Conjunction'))}
|
||
<Select
|
||
size="sm"
|
||
className="w-full"
|
||
menuPortalTarget={document.body}
|
||
options={joinOptions}
|
||
value={joinOptions.filter((option) => option.value === (rule.join ?? 'and'))}
|
||
onChange={(option) =>
|
||
option && updateRule(rule.id, { join: option.value as ScriptRule['join'] })
|
||
}
|
||
/>
|
||
</label>
|
||
) : (
|
||
<div className="min-w-0 shrink-0">
|
||
{fieldLabel(translate('::App.ScriptBuilder.Condition'))}
|
||
<div className="flex h-9 items-center text-xs font-semibold text-gray-400">
|
||
{translate('::App.ScriptBuilder.If')}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<label className={fieldClass}>
|
||
{fieldLabel(translate('::App.ScriptBuilder.Comparison'))}
|
||
<Select
|
||
size="sm"
|
||
className="w-full"
|
||
menuPortalTarget={document.body}
|
||
options={operatorOptions}
|
||
value={operatorOptions.filter((option) => option.value === condition.operator)}
|
||
onChange={(option) =>
|
||
option && updateCondition(rule, index, { operator: option.value })
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
{operator?.needsSource && dialect.conditionKinds.length > 1 && (
|
||
<label className={fieldClass}>
|
||
{fieldLabel(translate('::App.Listform.ListformField.SourceId'))}
|
||
<Select
|
||
size="sm"
|
||
className="w-full"
|
||
menuPortalTarget={document.body}
|
||
options={conditionKindOptions}
|
||
value={conditionKindOptions.filter(
|
||
(option) =>
|
||
option.value === (condition.kind ?? dialect.conditionKinds[0]?.value ?? ''),
|
||
)}
|
||
onChange={(option) =>
|
||
option && updateCondition(rule, index, { kind: option.value, source: '' })
|
||
}
|
||
/>
|
||
</label>
|
||
)}
|
||
|
||
{operator?.needsSource && (
|
||
<label className={fieldClass}>
|
||
{fieldLabel(translate('::App.Platform.Field'))}
|
||
{renderOptionInput(
|
||
kind?.optionSource,
|
||
condition.source,
|
||
(next) => updateCondition(rule, index, { source: next }),
|
||
kind?.placeholder || translate('::App.ScriptBuilder.Choose'),
|
||
)}
|
||
</label>
|
||
)}
|
||
|
||
{operator?.needsValue && (
|
||
<label className={fieldClass}>
|
||
{fieldLabel(translate('::App.Listform.ListformField.Value'))}
|
||
<Input
|
||
unstyle
|
||
className={controlClass}
|
||
value={condition.value}
|
||
placeholder={translate('::App.ScriptBuilder.ValuePlaceholder')}
|
||
onChange={(event) => updateCondition(rule, index, { value: event.target.value })}
|
||
/>
|
||
</label>
|
||
)}
|
||
|
||
{index > 0 && (
|
||
<Button
|
||
className="mb-0.5 shrink-0"
|
||
shape="circle"
|
||
size="xs"
|
||
type="button"
|
||
variant="plain"
|
||
title={translate('::App.ScriptBuilder.RemoveCondition')}
|
||
icon={<FaTimes />}
|
||
onClick={() => removeCondition(rule, index)}
|
||
/>
|
||
)}
|
||
</Fragment>
|
||
)
|
||
}
|
||
|
||
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">
|
||
{translate('::App.ScriptBuilder.ConditionCount', { count: conditions.length })} ·{' '}
|
||
{translate(rule.join === 'or' ? '::App.Platform.Or' : '::App.Platform.And')}
|
||
</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) ||
|
||
translate('::App.ScriptBuilder.IncompleteRuleWarning')
|
||
}
|
||
>
|
||
<FaExclamationTriangle size={9} /> {translate('::App.ScriptBuilder.Missing')}
|
||
</span>
|
||
)}
|
||
<div className="flex shrink-0 items-center">
|
||
<Button
|
||
shape="circle"
|
||
size="xs"
|
||
type="button"
|
||
variant="plain"
|
||
title={translate('::App.Platform.MoveUp')}
|
||
icon={<FaArrowUp />}
|
||
onClick={() => moveRule(index, -1)}
|
||
/>
|
||
<Button
|
||
shape="circle"
|
||
size="xs"
|
||
type="button"
|
||
variant="plain"
|
||
title={translate('::App.Platform.MoveDown')}
|
||
icon={<FaArrowDown />}
|
||
onClick={() => moveRule(index, 1)}
|
||
/>
|
||
<Button
|
||
shape="circle"
|
||
size="xs"
|
||
type="button"
|
||
variant="plain"
|
||
title={translate('::App.ScriptBuilder.DeleteRule')}
|
||
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>
|
||
|
||
{/* Tetikleyici ve kosullar tek bir akan satirda; dar panelde sarar. */}
|
||
<div className="mb-3 flex flex-wrap items-end gap-x-2 gap-y-2 rounded-md bg-gray-50 p-2 dark:bg-gray-800/40">
|
||
{dialect.triggers && (
|
||
<label className={fieldClass}>
|
||
{fieldLabel(translate('::App.ScriptBuilder.When'), triggerInfo?.help)}
|
||
<Select
|
||
size="sm"
|
||
className="w-full"
|
||
menuPortalTarget={document.body}
|
||
options={triggerOptions}
|
||
value={triggerOptions.filter(
|
||
(option) => option.value === (trigger ?? dialect.triggers?.[0]?.value),
|
||
)}
|
||
onChange={(option) => option && updateRule(rule.id, { trigger: option.value })}
|
||
/>
|
||
</label>
|
||
)}
|
||
|
||
{conditions.map((condition, conditionIndex) =>
|
||
renderCondition(rule, condition, conditionIndex),
|
||
)}
|
||
|
||
<Button
|
||
className="mb-0.5 shrink-0"
|
||
size="xs"
|
||
type="button"
|
||
icon={<FaPlus />}
|
||
title={translate('::App.ScriptBuilder.AddConditionHint')}
|
||
onClick={() => addCondition(rule)}
|
||
>
|
||
{translate('::App.ScriptBuilder.AddCondition')}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-x-3 gap-y-3 sm:grid-cols-2 xl:grid-cols-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
|
||
unstyle
|
||
className="w-full min-w-0 bg-transparent text-xs text-gray-700 outline-none dark:text-gray-100"
|
||
placeholder={translate('::App.ScriptBuilder.SearchHelp')}
|
||
value={helpSearch}
|
||
onChange={(event) => setHelpSearch(event.target.value)}
|
||
/>
|
||
<Button
|
||
shape="circle"
|
||
size="xs"
|
||
type="button"
|
||
variant="plain"
|
||
title={translate('::App.Platform.ClosePanel')}
|
||
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">
|
||
{translate('::App.ScriptBuilder.NoMatchingSnippet')}
|
||
</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">
|
||
{translate('::App.ScriptBuilder.NoMatchingShortcut')}
|
||
</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={translate('::App.ScriptBuilder.OpenHelpPanel')}
|
||
icon={<FaBook />}
|
||
onClick={() => setHelpOpen(true)}
|
||
>
|
||
{translate('::App.ScriptBuilder.Help')}
|
||
</Button>
|
||
)}
|
||
<Button size="sm" type="button" icon={<FaCopy />} onClick={copyScript}>
|
||
{translate(copied ? '::App.Platform.Copied' : '::App.Platform.Copy')}
|
||
</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 /> {translate('::App.Platform.Code')}
|
||
</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={translate('::App.ScriptBuilder.OpenWizardHint')}
|
||
onClick={openWizard}
|
||
>
|
||
<FaMagic /> {translate('::App.ScriptBuilder.Wizard')}
|
||
</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">{translate('::App.ScriptBuilder.CodeDivergedWarning')}</span>
|
||
<Button size="xs" type="button" onClick={() => setView('code')}>
|
||
{translate('::App.ScriptBuilder.ShowCode')}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{view === 'code' && staleHeader && (
|
||
<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">{translate('::App.ScriptBuilder.StaleHeader')}</span>
|
||
</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={translate('::App.ScriptBuilder.RegenerateHint')}
|
||
icon={<FaMagic />}
|
||
onClick={() => setCode(generatedScript)}
|
||
>
|
||
{translate('::App.ScriptBuilder.Regenerate')}
|
||
</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">
|
||
{translate('::App.ScriptBuilder.AddRule')}
|
||
</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${translate('::App.Platform.Example')}: ${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">
|
||
{translate('::App.ScriptBuilder.NoRulesYet')}
|
||
</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" />
|
||
{translate('::App.ScriptBuilder.ScriptPreview')}
|
||
</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
|
||
? translate('::App.ScriptBuilder.LineCount', {
|
||
count: generatedScript.split('\n').length - 1,
|
||
})
|
||
: translate('::App.Platform.Empty')}
|
||
</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 || `// ${translate('::App.ScriptBuilder.EmptyScriptHint')}`
|
||
}
|
||
options={{ ...editorOptions, readOnly: true }}
|
||
/>
|
||
</div>
|
||
<p className="mt-2 text-[11px] text-gray-400">
|
||
{translate('::App.ScriptBuilder.BuilderCommentHintPrefix')}{' '}
|
||
<code>{'// @builder'}</code>{' '}
|
||
{translate('::App.ScriptBuilder.BuilderCommentHintSuffix')}
|
||
</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">
|
||
{syntaxError && (
|
||
<p className="mr-auto self-center text-xs text-red-600">
|
||
{translate('::App.ScriptBuilder.SyntaxError', { message: syntaxError })}
|
||
</p>
|
||
)}
|
||
<Button size="sm" type="button" variant="plain" icon={<FaTimes />} onClick={onClose}>
|
||
{cancelText}
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
type="button"
|
||
variant="solid"
|
||
icon={<FaCheck />}
|
||
onClick={() => {
|
||
const script =
|
||
view === 'wizard'
|
||
? generatedScript
|
||
: staleHeader
|
||
? code.trim().split('\n').slice(1).join('\n').trim()
|
||
: code
|
||
// Runtime script'i derleyemezse tamamı (diğer kurallar dahil) sessizce çalışmaz;
|
||
// bozuk metin kaydedilmeden önce yakalanır.
|
||
const error = findSyntaxError(script)
|
||
setSyntaxError(error)
|
||
if (error) return
|
||
onApply(script)
|
||
onClose()
|
||
}}
|
||
>
|
||
{saveText}
|
||
</Button>
|
||
</Dialog.Footer>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
const AsyncFunction = Object.getPrototypeOf(async function noop() {}).constructor as new (
|
||
...args: string[]
|
||
) => unknown
|
||
|
||
/** Script runtime'ın derleyebileceği bir gövde mi; değilse derleyicinin mesajı. */
|
||
const findSyntaxError = (script: string) => {
|
||
try {
|
||
new AsyncFunction(script)
|
||
return ''
|
||
} catch (error) {
|
||
return error instanceof SyntaxError ? error.message : ''
|
||
}
|
||
}
|
||
|
||
export default ScriptBuilderDialog
|