sozsoft-platform/ui/src/components/scriptBuilder/ScriptBuilderDialog.tsx
2026-10-08 01:07:28 +03:00

1035 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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