2026-08-20 14:54:11 +00:00
|
|
|
|
import { Button, Dialog, Select } from '@/components/ui'
|
2026-08-14 11:32:31 +00:00
|
|
|
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
2026-08-10 09:08:08 +00:00
|
|
|
|
import Editor, { type Monaco } from '@monaco-editor/react'
|
|
|
|
|
|
import type * as monacoApi from 'monaco-editor'
|
2026-08-26 11:24:13 +00:00
|
|
|
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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'
|
2026-08-20 12:01:10 +00:00
|
|
|
|
import Input from '@/components/ui/Input'
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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 =
|
2026-08-26 11:24:13 +00:00
|
|
|
|
'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'
|
2026-08-10 09:08:08 +00:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-08-14 11:32:31 +00:00
|
|
|
|
saveLabel,
|
|
|
|
|
|
cancelLabel,
|
2026-08-10 09:08:08 +00:00
|
|
|
|
}: ScriptBuilderDialogProps) {
|
2026-08-14 11:32:31 +00:00
|
|
|
|
const { translate } = useLocalization()
|
2026-08-14 14:03:34 +00:00
|
|
|
|
const saveText = saveLabel ?? translate('::App.Platform.Save')
|
|
|
|
|
|
const cancelText = cancelLabel ?? translate('::App.Platform.Cancel')
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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ı.
|
|
|
|
|
|
*/
|
2026-08-14 11:32:31 +00:00
|
|
|
|
const updateCondition = (
|
|
|
|
|
|
rule: ScriptRule,
|
|
|
|
|
|
index: number,
|
|
|
|
|
|
patch: Partial<ScriptRuleCondition>,
|
|
|
|
|
|
) => {
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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)
|
2026-08-14 11:32:31 +00:00
|
|
|
|
updateRule(
|
|
|
|
|
|
rule.id,
|
|
|
|
|
|
conditions.length ? { conditions } : { conditions: undefined, join: undefined },
|
|
|
|
|
|
)
|
2026-08-10 09:08:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
|
|
|
|
|
unstyle
|
2026-08-10 09:08:08 +00:00
|
|
|
|
className={controlClass}
|
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
|
value={selectedValue}
|
|
|
|
|
|
onChange={(event) => onChange(event.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<Select
|
2026-08-26 11:24:13 +00:00
|
|
|
|
isClearable
|
2026-08-20 14:54:11 +00:00
|
|
|
|
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 ?? '')}
|
|
|
|
|
|
/>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const renderParam = (rule: ScriptRule, param: ScriptRecipeParam) => {
|
|
|
|
|
|
if (param.type === 'fieldList') {
|
|
|
|
|
|
const selectedFields = rule.fields ?? []
|
|
|
|
|
|
const options = dialect.getOptions(param.optionSource || '')
|
|
|
|
|
|
return (
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<div key={param.key} className="min-w-0 sm:col-span-2 xl:col-span-3">
|
|
|
|
|
|
{fieldLabel(param.label, param.help)}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
))}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<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
|
2026-08-10 09:08:08 +00:00
|
|
|
|
if (!next || selectedFields.includes(next)) return
|
|
|
|
|
|
updateRule(rule.id, { fields: [...selectedFields, next] })
|
|
|
|
|
|
}}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
/>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const currentValue = rule.params[param.key] ?? ''
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<label key={param.key} className="min-w-0">
|
2026-08-10 09:08:08 +00:00
|
|
|
|
<span className="mb-1 block truncate text-xs text-gray-500" title={param.help}>
|
|
|
|
|
|
{param.label}
|
2026-08-26 11:24:13 +00:00
|
|
|
|
{param.optional && (
|
|
|
|
|
|
<span className="ml-1 text-gray-300">
|
|
|
|
|
|
({translate('::App.ScriptBuilder.Optional')})
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
{param.type === 'option' &&
|
|
|
|
|
|
renderOptionInput(
|
|
|
|
|
|
param.optionSource,
|
|
|
|
|
|
currentValue,
|
|
|
|
|
|
(next) => updateParam(rule.id, param.key, next),
|
2026-08-14 11:32:31 +00:00
|
|
|
|
param.placeholder || translate('::App.ScriptBuilder.Choose'),
|
2026-08-10 09:08:08 +00:00
|
|
|
|
)}
|
|
|
|
|
|
{param.type === 'select' && (
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<Select
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
menuPortalTarget={document.body}
|
|
|
|
|
|
options={(param.choices ?? []).map((choice) => ({
|
|
|
|
|
|
value: choice.value,
|
|
|
|
|
|
label: choice.label,
|
|
|
|
|
|
}))}
|
|
|
|
|
|
value={(param.choices ?? [])
|
|
|
|
|
|
.filter((choice) => choice.value === currentValue)
|
|
|
|
|
|
.map((choice) => ({ value: choice.value, label: choice.label }))}
|
|
|
|
|
|
onChange={(option) =>
|
|
|
|
|
|
option && updateParam(rule.id, param.key, option.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
)}
|
|
|
|
|
|
{(param.type === 'text' || param.type === 'number') && (
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
|
|
|
|
|
unstyle
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 14:54:11 +00:00
|
|
|
|
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,
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
2026-08-26 11:24:13 +00:00
|
|
|
|
/** 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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]
|
|
|
|
|
|
|
2026-08-26 11:24:13 +00:00
|
|
|
|
// Kosul parcalari tek bir sarmalayici yerine dogrudan ust flex satirina
|
|
|
|
|
|
// giriyor; boylece tetikleyici ve kosullar yan yana dizilip birlikte sarar.
|
2026-08-10 09:08:08 +00:00
|
|
|
|
return (
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<Fragment key={index}>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
{index > 0 ? (
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<label className="min-w-0 basis-[6.5rem] grow-0">
|
|
|
|
|
|
{fieldLabel(translate('::App.ScriptBuilder.Conjunction'))}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<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'] })
|
2026-08-10 09:08:08 +00:00
|
|
|
|
}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
/>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
) : (
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<div className="min-w-0 shrink-0">
|
|
|
|
|
|
{fieldLabel(translate('::App.ScriptBuilder.Condition'))}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<div className="flex h-9 items-center text-xs font-semibold text-gray-400">
|
|
|
|
|
|
{translate('::App.ScriptBuilder.If')}
|
|
|
|
|
|
</div>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<label className={fieldClass}>
|
|
|
|
|
|
{fieldLabel(translate('::App.ScriptBuilder.Comparison'))}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
{operator?.needsSource && dialect.conditionKinds.length > 1 && (
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<label className={fieldClass}>
|
|
|
|
|
|
{fieldLabel(translate('::App.Listform.ListformField.SourceId'))}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<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: '' })
|
2026-08-10 09:08:08 +00:00
|
|
|
|
}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
/>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{operator?.needsSource && (
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<label className={fieldClass}>
|
|
|
|
|
|
{fieldLabel(translate('::App.Platform.Field'))}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
{renderOptionInput(
|
|
|
|
|
|
kind?.optionSource,
|
|
|
|
|
|
condition.source,
|
|
|
|
|
|
(next) => updateCondition(rule, index, { source: next }),
|
2026-08-14 11:32:31 +00:00
|
|
|
|
kind?.placeholder || translate('::App.ScriptBuilder.Choose'),
|
2026-08-10 09:08:08 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{operator?.needsValue && (
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<label className={fieldClass}>
|
|
|
|
|
|
{fieldLabel(translate('::App.Listform.ListformField.Value'))}
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
|
|
|
|
|
unstyle
|
2026-08-10 09:08:08 +00:00
|
|
|
|
className={controlClass}
|
|
|
|
|
|
value={condition.value}
|
2026-08-26 11:24:13 +00:00
|
|
|
|
placeholder={translate('::App.ScriptBuilder.ValuePlaceholder')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
onChange={(event) => updateCondition(rule, index, { value: event.target.value })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{index > 0 && (
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<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)}
|
|
|
|
|
|
/>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
)}
|
2026-08-26 11:24:13 +00:00
|
|
|
|
</Fragment>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{translate('::App.ScriptBuilder.ConditionCount', { count: conditions.length })} ·{' '}
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate(rule.join === 'or' ? '::App.Platform.Or' : '::App.Platform.And')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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) ||
|
2026-08-14 11:32:31 +00:00
|
|
|
|
translate('::App.ScriptBuilder.IncompleteRuleWarning')
|
2026-08-10 09:08:08 +00:00
|
|
|
|
}
|
|
|
|
|
|
>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<FaExclamationTriangle size={9} /> {translate('::App.ScriptBuilder.Missing')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex shrink-0 items-center">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
size="xs"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="plain"
|
2026-08-14 11:32:31 +00:00
|
|
|
|
title={translate('::App.Platform.MoveUp')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
icon={<FaArrowUp />}
|
|
|
|
|
|
onClick={() => moveRule(index, -1)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
size="xs"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="plain"
|
2026-08-14 11:32:31 +00:00
|
|
|
|
title={translate('::App.Platform.MoveDown')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
icon={<FaArrowDown />}
|
|
|
|
|
|
onClick={() => moveRule(index, 1)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
size="xs"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="plain"
|
2026-08-14 11:32:31 +00:00
|
|
|
|
title={translate('::App.ScriptBuilder.DeleteRule')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-08-26 11:24:13 +00:00
|
|
|
|
{/* 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">
|
2026-08-10 09:08:08 +00:00
|
|
|
|
{dialect.triggers && (
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<label className={fieldClass}>
|
|
|
|
|
|
{fieldLabel(translate('::App.ScriptBuilder.When'), triggerInfo?.help)}
|
2026-08-20 14:54:11 +00:00
|
|
|
|
<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 })
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{conditions.map((condition, conditionIndex) =>
|
|
|
|
|
|
renderCondition(rule, condition, conditionIndex),
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<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>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<div className="grid grid-cols-1 gap-x-3 gap-y-3 sm:grid-cols-2 xl:grid-cols-3">
|
2026-08-10 09:08:08 +00:00
|
|
|
|
{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} />
|
2026-08-20 12:01:10 +00:00
|
|
|
|
<Input
|
|
|
|
|
|
unstyle
|
2026-08-10 09:08:08 +00:00
|
|
|
|
className="w-full min-w-0 bg-transparent text-xs text-gray-700 outline-none dark:text-gray-100"
|
2026-08-14 11:32:31 +00:00
|
|
|
|
placeholder={translate('::App.ScriptBuilder.SearchHelp')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
value={helpSearch}
|
|
|
|
|
|
onChange={(event) => setHelpSearch(event.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
shape="circle"
|
|
|
|
|
|
size="xs"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="plain"
|
2026-08-14 11:32:31 +00:00
|
|
|
|
title={translate('::App.Platform.ClosePanel')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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 && (
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<span className="text-[11px] text-gray-400">
|
|
|
|
|
|
{translate('::App.ScriptBuilder.NoMatchingSnippet')}
|
|
|
|
|
|
</span>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</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 && (
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<span className="text-[11px] text-gray-400">
|
|
|
|
|
|
{translate('::App.ScriptBuilder.NoMatchingShortcut')}
|
|
|
|
|
|
</span>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</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"
|
2026-08-14 11:32:31 +00:00
|
|
|
|
title={translate('::App.ScriptBuilder.OpenHelpPanel')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
icon={<FaBook />}
|
|
|
|
|
|
onClick={() => setHelpOpen(true)}
|
|
|
|
|
|
>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{translate('::App.ScriptBuilder.Help')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Button size="sm" type="button" icon={<FaCopy />} onClick={copyScript}>
|
2026-08-14 14:03:34 +00:00
|
|
|
|
{translate(copied ? '::App.Platform.Copied' : '::App.Platform.Copy')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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')}
|
|
|
|
|
|
>
|
2026-08-26 11:24:13 +00:00
|
|
|
|
<FaCode /> {translate('::App.Platform.Code')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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'
|
|
|
|
|
|
}`}
|
2026-08-14 11:32:31 +00:00
|
|
|
|
title={translate('::App.ScriptBuilder.OpenWizardHint')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
onClick={openWizard}
|
|
|
|
|
|
>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<FaMagic /> {translate('::App.ScriptBuilder.Wizard')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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" />
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<span className="flex-1">{translate('::App.ScriptBuilder.CodeDivergedWarning')}</span>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
<Button size="xs" type="button" onClick={() => setView('code')}>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{translate('::App.ScriptBuilder.ShowCode')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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"
|
2026-08-14 11:32:31 +00:00
|
|
|
|
title={translate('::App.ScriptBuilder.RegenerateHint')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
icon={<FaMagic />}
|
|
|
|
|
|
onClick={() => setCode(generatedScript)}
|
|
|
|
|
|
>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{translate('::App.ScriptBuilder.Regenerate')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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} />
|
2026-08-14 11:32:31 +00:00
|
|
|
|
<span className="flex-1 text-sm font-semibold">
|
|
|
|
|
|
{translate('::App.ScriptBuilder.AddRule')}
|
|
|
|
|
|
</span>
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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"
|
2026-08-14 14:03:34 +00:00
|
|
|
|
title={`${recipe.summary}\n\n${translate('::App.Platform.Example')}: ${recipe.example}`}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
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">
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{translate('::App.ScriptBuilder.NoRulesYet')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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" />
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{translate('::App.ScriptBuilder.ScriptPreview')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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">
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{generatedScript
|
|
|
|
|
|
? translate('::App.ScriptBuilder.LineCount', {
|
|
|
|
|
|
count: generatedScript.split('\n').length - 1,
|
|
|
|
|
|
})
|
2026-08-14 14:03:34 +00:00
|
|
|
|
: translate('::App.Platform.Empty')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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"
|
2026-08-14 11:32:31 +00:00
|
|
|
|
value={
|
|
|
|
|
|
generatedScript || `// ${translate('::App.ScriptBuilder.EmptyScriptHint')}`
|
|
|
|
|
|
}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
options={{ ...editorOptions, readOnly: true }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="mt-2 text-[11px] text-gray-400">
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{translate('::App.ScriptBuilder.BuilderCommentHintPrefix')}{' '}
|
|
|
|
|
|
<code>{'// @builder'}</code>{' '}
|
|
|
|
|
|
{translate('::App.ScriptBuilder.BuilderCommentHintSuffix')}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</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}>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{cancelText}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="solid"
|
|
|
|
|
|
icon={<FaCheck />}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
onApply(view === 'wizard' ? generatedScript : code)
|
|
|
|
|
|
onClose()
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-08-14 11:32:31 +00:00
|
|
|
|
{saveText}
|
2026-08-10 09:08:08 +00:00
|
|
|
|
</Button>
|
|
|
|
|
|
</Dialog.Footer>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ScriptBuilderDialog
|