sozsoft-platform/ui/src/views/developerKit/VisualComponentDesigner.tsx

3551 lines
142 KiB
TypeScript
Raw Normal View History

2026-08-05 20:51:43 +00:00
import Editor from '@monaco-editor/react'
import axios from 'axios'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
FaArrowLeft,
FaBolt,
FaCheckCircle,
FaChevronDown,
FaChevronRight,
FaCode,
FaDesktop,
FaDatabase,
FaEye,
FaEdit,
FaLayerGroup,
FaMobileAlt,
FaMousePointer,
FaPlus,
FaPlay,
FaRedo,
FaRegSave,
FaSearch,
FaSlidersH,
FaSync,
FaTabletAlt,
FaTrash,
FaUndo,
} from 'react-icons/fa'
import { useComponents } from '@/contexts/ComponentContext'
import type { CrudEndpoint, CustomComponent } from '@/proxy/developerKit/models'
import { ROUTES_ENUM } from '@/routes/route.constant'
import apiService from '@/services/api.service'
import { developerKitService } from '@/services/developerKit.service'
import { Button, Notification, toast } from '@/components/ui'
2026-08-06 13:17:59 +00:00
import StyleModal from '@/components/codeLayout/StyleModal'
2026-08-05 20:51:43 +00:00
import VisualCanvas, { DESIGNER_DRAG_TYPE } from '@/components/visualDesigner/VisualCanvas'
import {
DESIGNER_DATA_COMPONENT_NAMES,
2026-08-05 20:51:43 +00:00
DESIGNER_PRIMARY_EVENTS,
getDesignerCatalog,
getDesignerEventSnippet,
} from '@/components/visualDesigner/catalog'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import {
createDesignerId,
createEmptyDesignerDocument,
findDesignerNode,
getDesignerValueByPath,
resolveDesignerResponse,
walkDesignerNodes,
type DesignerComponentDefinition,
type DesignerBinding,
type DesignerDataSource,
type DesignerDocument,
type DesignerNode,
type DesignerPropertyInfo,
} from '@/components/visualDesigner/types'
2026-08-06 13:17:59 +00:00
import { useLocalization } from '@/utils/hooks/useLocalization'
2026-08-05 20:51:43 +00:00
type WorkspaceTab = 'design' | 'data' | 'code' | 'javascript'
type InspectorTab = 'properties' | 'data' | 'tree'
type DataTestResult = { status: 'loading' | 'success' | 'error'; message: string }
type EndpointResultModal = { source: DesignerDataSource; result: unknown }
type DataField = { path: string; type: string; preview: string }
type StaticSelectOption = { label: string; value: unknown }
type DataSourceCatalogItem = {
attached: boolean
key: string
origin: string
originType: 'current' | 'component' | 'crud'
ownerComponentId?: string
source: DesignerDataSource
}
const DATA_BINDABLE_PROPERTY_NAMES = new Set([
'items',
'data',
'dataSource',
'options',
'value',
'checked',
'children',
'content',
'text',
'label',
'title',
'header',
'headerExtra',
'footer',
'description',
'placeholder',
'src',
'alt',
'disabled',
'loading',
])
const OPTION_DATA_COMPONENT_NAMES = new Set([
'Select',
'AutoComplete',
'Menu',
'Dropdown',
2026-08-06 13:17:59 +00:00
'Pagination',
'Radio.Group',
])
const TABULAR_DATA_COMPONENT_NAMES = new Set(['Grid'])
2026-08-05 20:51:43 +00:00
const isOptionDataComponent = (type?: string) =>
Boolean(type && OPTION_DATA_COMPONENT_NAMES.has(type))
const getOptionDataProperty = (type?: string) =>
2026-08-06 13:17:59 +00:00
['Menu', 'Dropdown', 'Pagination', 'Radio.Group'].includes(type || '') ? 'items' : 'options'
2026-08-05 20:51:43 +00:00
const isTabularDataComponent = (type?: string) =>
Boolean(type && TABULAR_DATA_COMPONENT_NAMES.has(type))
const PROPERTY_EXAMPLE_VALUES: Record<string, unknown> = {
children: 'Örnek içerik',
className: 'p-4 rounded-lg shadow-sm',
id: 'component-id',
label: 'Örnek başlık',
name: 'exampleName',
placeholder: 'Bir değer girin…',
src: '/img/example.png',
title: 'Örnek başlık',
url: '/api/app/example',
}
const getPropertyExampleValue = (
property: DesignerPropertyInfo,
currentValue: unknown,
): unknown => {
const hasCurrentValue =
currentValue !== undefined &&
currentValue !== null &&
currentValue !== '' &&
(!Array.isArray(currentValue) || currentValue.length > 0) &&
(typeof currentValue !== 'object' ||
Array.isArray(currentValue) ||
Object.keys(currentValue as Record<string, unknown>).length > 0)
if (hasCurrentValue || typeof currentValue === 'boolean' || typeof currentValue === 'number') {
return currentValue
}
if (property.options?.length) return property.options[0]
if (PROPERTY_EXAMPLE_VALUES[property.name] !== undefined) {
return PROPERTY_EXAMPLE_VALUES[property.name]
}
if (property.name.toLowerCase().includes('color')) return '#0ea5e9'
if (property.type === 'boolean') return true
if (property.type === 'number') return 16
if (property.type === 'array') return [{ label: 'Örnek', value: 'example' }]
if (property.type === 'object') {
return property.name === 'style' ? { padding: 16, borderRadius: 8 } : { key: 'value' }
}
return 'Örnek değer'
}
const getPropertySnippet = (property: DesignerPropertyInfo, currentValue: unknown) => {
const exampleValue = getPropertyExampleValue(property, currentValue)
return typeof exampleValue === 'string'
? `${property.name}=${JSON.stringify(exampleValue)}`
: `${property.name}={${JSON.stringify(exampleValue)}}`
}
const getDataValueType = (value: unknown) => {
if (value === null) return 'null'
if (Array.isArray(value)) return 'array'
return typeof value
}
const getDataPreview = (value: unknown) => {
const text =
typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value ?? 'undefined'))
return text.length > 48 ? `${text.slice(0, 45)}` : text
}
const discoverDataFields = (sample: unknown): DataField[] => {
const fields: DataField[] = []
const root = Array.isArray(sample) ? sample[0] : sample
const visit = (value: unknown, path: string, depth: number) => {
if (!path || fields.length >= 100) {
if (!path && value && typeof value === 'object' && !Array.isArray(value)) {
Object.entries(value as Record<string, unknown>).forEach(([key, child]) =>
visit(child, key, depth),
)
}
return
}
const valueType = getDataValueType(value)
if (valueType === 'object' && depth < 4) {
Object.entries(value as Record<string, unknown>).forEach(([key, child]) =>
visit(child, `${path}.${key}`, depth + 1),
)
return
}
fields.push({ path, type: valueType, preview: getDataPreview(value) })
if (Array.isArray(value) && value[0] && typeof value[0] === 'object' && depth < 4) {
Object.entries(value[0] as Record<string, unknown>).forEach(([key, child]) =>
visit(child, `${path}.${key}`, depth + 1),
)
}
}
visit(root, '', 0)
return fields
}
const findDesignerAncestors = (
nodes: DesignerNode[],
id: string | null,
ancestors: DesignerNode[] = [],
): DesignerNode[] | null => {
if (!id) return null
for (const node of nodes) {
if (node.id === id) return ancestors
const found = findDesignerAncestors(node.children, id, [...ancestors, node])
if (found) return found
}
return null
}
const getSaveErrorMessage = (error: unknown) => {
if (!axios.isAxiosError(error)) {
return error instanceof Error ? error.message : 'Bileşen kaydedilemedi.'
}
const responseData = error.response?.data as
| { error?: { message?: string; details?: string }; message?: string }
| undefined
return (
responseData?.error?.details ||
responseData?.error?.message ||
responseData?.message ||
error.message ||
'Bileşen kaydedilemedi.'
)
}
const isRunnableDataSourceUrl = (url: string) => {
const normalizedUrl = url.trim().replace(/\/+$/, '')
return normalizedUrl.startsWith('/api/') && normalizedUrl !== '/api/app'
}
const TOOLBOX_GROUP_LABELS: Record<DesignerComponentDefinition['toolboxGroup'], string> = {
layout: 'Layouts',
html: 'HTML Elements',
platform: 'Platform Views',
data: 'Data',
ui: 'Sozsoft UI',
custom: 'My Components',
}
const TOOLBOX_GROUP_ORDER: DesignerComponentDefinition['toolboxGroup'][] = [
'layout',
'html',
'platform',
'data',
'ui',
'custom',
]
const CANVAS_WIDTHS: Record<DesignerDocument['canvas']['width'], string> = {
responsive: '100%',
desktop: '1180px',
tablet: '768px',
mobile: '390px',
}
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value))
const normalizeTableDimension = (value: unknown, fallback: number) =>
Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
2026-08-05 20:51:43 +00:00
const updateNodeTree = (
nodes: DesignerNode[],
id: string,
updater: (node: DesignerNode) => DesignerNode,
): DesignerNode[] =>
nodes.map((node) => {
if (node.id === id) return updater(node)
return { ...node, children: updateNodeTree(node.children, id, updater) }
})
const removeNodeTree = (nodes: DesignerNode[], id: string): DesignerNode[] =>
nodes
.filter((node) => node.id !== id)
.map((node) => ({
...node,
children: removeNodeTree(node.children, id),
}))
const removeDataSourceBindings = (nodes: DesignerNode[], sourceId: string): DesignerNode[] =>
nodes.map((node) => ({
...node,
bindings: Object.fromEntries(
Object.entries(node.bindings || {}).filter(([, binding]) => binding.sourceId !== sourceId),
),
children: removeDataSourceBindings(node.children, sourceId),
}))
const insertNodeTree = (
nodes: DesignerNode[],
nodeToInsert: DesignerNode,
parentId: string | null,
): DesignerNode[] => {
if (!parentId) return [...nodes, nodeToInsert]
return updateNodeTree(nodes, parentId, (node) => ({
...node,
children: [...node.children, nodeToInsert],
}))
}
const duplicateInTree = (nodes: DesignerNode[], id: string): DesignerNode[] => {
const result: DesignerNode[] = []
nodes.forEach((node) => {
result.push({ ...node, children: duplicateInTree(node.children, id) })
if (node.id === id) {
const copy = clone(node)
const renewIds = (item: DesignerNode) => {
item.id = createDesignerId()
item.children.forEach(renewIds)
}
renewIds(copy)
result.push(copy)
}
})
return result
}
const moveInTree = (nodes: DesignerNode[], id: string, direction: -1 | 1): DesignerNode[] => {
const index = nodes.findIndex((node) => node.id === id)
if (index >= 0) {
const target = index + direction
if (target < 0 || target >= nodes.length) return nodes
const next = [...nodes]
;[next[index], next[target]] = [next[target], next[index]]
return next
}
return nodes.map((node) => ({ ...node, children: moveInTree(node.children, id, direction) }))
}
2026-08-06 13:17:59 +00:00
const insertRelativeToNode = (
nodes: DesignerNode[],
targetId: string,
nodeToInsert: DesignerNode,
placement: 'before' | 'after',
): DesignerNode[] => {
const targetIndex = nodes.findIndex((node) => node.id === targetId)
if (targetIndex >= 0) {
const next = [...nodes]
next.splice(targetIndex + (placement === 'after' ? 1 : 0), 0, nodeToInsert)
return next
}
return nodes.map((node) => ({
...node,
children: insertRelativeToNode(node.children, targetId, nodeToInsert, placement),
}))
}
const reorderNodeTree = (
nodes: DesignerNode[],
sourceId: string,
targetId: string,
placement: 'before' | 'after',
): DesignerNode[] => {
const source = findDesignerNode(nodes, sourceId)
const target = findDesignerNode(nodes, targetId)
if (!source || !target || sourceId === targetId || findDesignerNode(source.children, targetId)) {
return nodes
}
const movingNode = { ...source, slot: target.slot }
return insertRelativeToNode(removeNodeTree(nodes, sourceId), targetId, movingNode, placement)
}
2026-08-05 20:51:43 +00:00
const definitionToNode = (definition: DesignerComponentDefinition): DesignerNode => {
const props: Record<string, unknown> = {}
const events: Record<string, string> = {}
definition.properties.forEach((property) => {
if (property.category === 'events' || property.type === 'function') {
events[property.name] = typeof property.value === 'string' ? property.value : ''
} else {
props[property.name] = clone(property.value)
}
})
return {
id: createDesignerId(),
type: definition.name,
kind: definition.kind,
props,
events,
bindings: {},
children: [],
}
}
const normalizeDesignerDocument = (document: DesignerDocument): DesignerDocument => {
2026-08-06 13:17:59 +00:00
const normalizeNodes = (nodes: DesignerNode[]): DesignerNode[] =>
nodes.map((node) => {
const props = { ...(node.props || {}) }
const events = { ...(node.events || {}) }
const bindings = { ...(node.bindings || {}) }
if (node.type === 'Select') {
const legacyPropAliases: Record<string, string> = {
clearable: 'isClearable',
disabled: 'isDisabled',
multiple: 'isMulti',
searchable: 'isSearchable',
}
Object.entries(legacyPropAliases).forEach(([legacyName, runtimeName]) => {
if (props[runtimeName] === undefined && props[legacyName] !== undefined) {
props[runtimeName] = props[legacyName]
2026-08-05 20:51:43 +00:00
}
2026-08-06 13:17:59 +00:00
delete props[legacyName]
2026-08-05 20:51:43 +00:00
})
2026-08-06 13:17:59 +00:00
if (events.onInputChange === undefined && events.onSearch !== undefined) {
events.onInputChange = events.onSearch
}
delete events.onSearch
}
if (node.type === 'Menu') {
if (!Array.isArray(props.items) && Array.isArray(props.options)) {
props.items = props.options
}
if (!bindings.items && bindings.options) bindings.items = bindings.options
delete props.options
delete props.variant
delete bindings.options
}
if (node.type === 'Table') {
const legacyRows = Array.isArray(props.items)
? props.items
: Array.isArray(props.data)
? props.data
: []
const firstLegacyRow = legacyRows.find(
(item) => item && typeof item === 'object' && !Array.isArray(item),
) as Record<string, unknown> | undefined
const legacyColumns = Array.isArray(props.dataColumns)
? props.dataColumns
: Array.isArray(props.columns)
? props.columns
: firstLegacyRow
? Object.keys(firstLegacyRow)
: []
props.rowCount = normalizeTableDimension(props.rowCount, legacyRows.length || 2)
props.columnCount = normalizeTableDimension(props.columnCount, legacyColumns.length || 3)
delete props.data
delete props.columns
delete props.items
delete props.dataColumns
delete bindings.data
delete bindings.items
}
let children = normalizeNodes(node.children || [])
if (node.type === 'Table') {
const columnCount = normalizeTableDimension(props.columnCount, 3)
children = children.map((child, index) => ({
...child,
slot: child.slot || `table:${Math.floor(index / columnCount)}:${index % columnCount}`,
}))
}
return {
...node,
props,
events,
bindings,
children,
}
})
2026-08-05 20:51:43 +00:00
return {
...document,
nodes: normalizeNodes(document.nodes),
2026-08-06 13:17:59 +00:00
canvas: { width: document.canvas?.width || 'responsive' },
2026-08-05 20:51:43 +00:00
dataSources: Array.isArray(document.dataSources) ? document.dataSources : [],
}
}
const parseDesignerDocument = (rawProps: string | undefined): DesignerDocument | null => {
if (!rawProps) return null
try {
const parsed = JSON.parse(rawProps)
const document = parsed?.visualDesigner
if (document?.version === 1 && Array.isArray(document.nodes)) {
return normalizeDesignerDocument(document)
}
} catch {
return null
}
return null
}
const parseDesignerDocumentFromCode = (code: string | undefined): DesignerDocument | null => {
2026-08-06 13:17:59 +00:00
const encodedDocument = code?.match(/\/\*__SOZSOFT_VISUAL_DESIGNER__(.*?)__\*\//)?.[1]
2026-08-05 20:51:43 +00:00
if (!encodedDocument) return null
try {
const document = JSON.parse(decodeURIComponent(encodedDocument)) as DesignerDocument
return document?.version === 1 && Array.isArray(document.nodes)
? normalizeDesignerDocument(document)
: null
} catch {
return null
}
}
const parseComponentDefaultProps = (code: string): Record<string, unknown> => {
const defaults: Record<string, unknown> = {}
const parameters = code.match(
/(?:const\s+[A-Za-z_$][\w$]*\s*(?::[^=]+)?=\s*|function\s+[A-Za-z_$][\w$]*\s*)\(\s*\{([\s\S]*?)\}\s*\)/,
)?.[1]
if (!parameters) return defaults
for (const match of parameters.matchAll(
/([A-Za-z_$][\w$]*)\s*=\s*("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|true|false|null|-?\d+(?:\.\d+)?)/g,
)) {
const rawValue = match[2]
try {
defaults[match[1]] = rawValue.startsWith("'")
? rawValue.slice(1, -1).replace(/\\'/g, "'")
: JSON.parse(rawValue)
} catch {
defaults[match[1]] = rawValue
}
}
return defaults
}
const parseGeneratedJsxProps = (
source: string,
expressionValues: Record<string, unknown> = {},
): Record<string, unknown> => {
const props: Record<string, unknown> = {}
for (const match of source.matchAll(/([A-Za-z_$][\w$]*)="((?:\\.|[^"])*)"/g)) {
try {
props[match[1]] = JSON.parse(`"${match[2]}"`)
} catch {
props[match[1]] = match[2]
}
}
2026-08-06 13:17:59 +00:00
for (const match of source.matchAll(
/([A-Za-z_$][\w$]*)=\{(true|false|null|-?\d+(?:\.\d+)?)\}/g,
)) {
2026-08-05 20:51:43 +00:00
props[match[1]] =
match[2] === 'true'
? true
: match[2] === 'false'
? false
: match[2] === 'null'
? null
: Number(match[2])
}
for (const match of source.matchAll(/([A-Za-z_$][\w$]*)=\{([A-Za-z_$][\w$]*)\}/g)) {
if (Object.prototype.hasOwnProperty.call(expressionValues, match[2])) {
props[match[1]] = expressionValues[match[2]]
}
}
return props
}
const migrateGeneratedCodeToDesignerDocument = (
code: string | undefined,
definitions: Map<string, DesignerComponentDefinition>,
): DesignerDocument | null => {
if (!code || !code.includes('export default')) return null
const document = createEmptyDesignerDocument('visual')
const componentDefaultProps = parseComponentDefaultProps(code)
const positionedNodes: Array<{ index: number; node: DesignerNode }> = []
const addNode = (
index: number,
type: string,
kind: DesignerNode['kind'],
props: Record<string, unknown> = {},
bindings: DesignerNode['bindings'] = {},
id = createDesignerId(),
) =>
positionedNodes.push({
index,
node: { id, type, kind, props, bindings, events: {}, children: [] },
})
for (const stateMatch of code.matchAll(
/const \[data_([A-Za-z0-9_$]+), setData_\1\] = React\.useState\(null\)([\s\S]*?)(?=\n\s*const \[data_|\n\s*const [A-Za-z_$][\w$]* = \(\{|$)/g,
)) {
const sourceId = stateMatch[1]
const block = stateMatch[2]
const urlMatch = block.match(/fetchData\(\{\s*url:\s*("(?:\\.|[^"])*")/)
if (!urlMatch) continue
const responsePathMatch = block.match(
new RegExp(`setData_${sourceId}\\(getByPath\\(response\\.data,\\s*("(?:\\\\.|[^"])*")`),
)
document.dataSources.push({
id: sourceId,
name: sourceId.replace(/^source_/, '').replace(/_/g, ' '),
method: 'GET',
url: JSON.parse(urlMatch[1]),
responsePath: responsePathMatch ? JSON.parse(responsePathMatch[1]) : '',
})
}
2026-08-06 13:17:59 +00:00
for (const match of code.matchAll(
/<UiKit\.(Select|AutoComplete|Menu|Pagination)\b([^>]*?)(?:\/>|>)/g,
)) {
2026-08-05 20:51:43 +00:00
const type = match[1]
const attributes = match[2]
const props = parseGeneratedJsxProps(attributes, componentDefaultProps)
const bindings: DesignerNode['bindings'] = {}
if (type === 'Select' || type === 'AutoComplete' || type === 'Menu') {
const optionsMatch = attributes.match(
/options=\{toSelectOptions\(getByPath\(data_([A-Za-z0-9_$]+),\s*("(?:\\.|[^"])*")\),\s*("(?:\\.|[^"])*"),\s*("(?:\\.|[^"])*")\)\}/,
)
if (optionsMatch) {
bindings.options = {
sourceId: optionsMatch[1],
path: JSON.parse(optionsMatch[2]),
labelPath: JSON.parse(optionsMatch[3]),
valuePath: JSON.parse(optionsMatch[4]),
}
}
}
addNode(match.index, type, 'ui', props, bindings)
}
for (const match of code.matchAll(
/const tableItems_([A-Za-z0-9_$]+)\s*=\s*(\[[^\n]*\])[\s\S]*?const tableColumns_\1\s*=\s*(\[[^\n]*\])[\s\S]*?<UiKit\.Table\b([^>]*)>/g,
)) {
try {
const legacyItems = JSON.parse(match[2]) as unknown[]
const legacyColumns = JSON.parse(match[3]) as unknown[]
2026-08-05 20:51:43 +00:00
addNode(
match.index,
'Table',
'ui',
{
...parseGeneratedJsxProps(match[4], componentDefaultProps),
rowCount: normalizeTableDimension(legacyItems.length, 2),
columnCount: normalizeTableDimension(legacyColumns.length, 3),
2026-08-05 20:51:43 +00:00
},
{},
match[1],
)
} catch {
// Ignore a table block that is not in the generated designer format.
}
}
for (const match of code.matchAll(
/const gridItems_([A-Za-z0-9_$]+)\s*=\s*([^\n]+)[\s\S]*?const gridColumns_\1\s*=\s*(\[[^\n]*\])/g,
)) {
const bindingMatch = match[2].match(/getByPath\(data_([A-Za-z0-9_$]+),\s*("(?:\\.|[^"])*")\)/)
const bindings: DesignerNode['bindings'] = {}
if (bindingMatch) {
bindings.items = { sourceId: bindingMatch[1], path: JSON.parse(bindingMatch[2]) }
}
try {
addNode(
match.index,
'Grid',
'ui',
{
2026-08-06 13:17:59 +00:00
className:
code
.slice(Math.max(0, match.index - 250), match.index)
.match(/<div className="([^"]*)"[^>]*>\s*\{\(\(\) => \{$/)?.[1] || '',
2026-08-05 20:51:43 +00:00
dataColumns: JSON.parse(match[3]),
compact: !code.slice(match.index, match.index + 5000).includes('px-3 py-2'),
hoverable: code.slice(match.index, match.index + 5000).includes('hover:bg-'),
overflow: code.slice(match.index, match.index + 5000).includes('overflow-auto'),
borderlessRow: !code.slice(match.index, match.index + 5000).includes('border-b'),
},
bindings,
match[1],
)
} catch {
// Ignore a grid block that is not in the generated designer format.
}
}
for (const match of code.matchAll(/<PlatformViewHost\b([^>]*?)\/>/g)) {
const props = parseGeneratedJsxProps(match[1], componentDefaultProps)
const view = String(props.view || '')
const platformTypes: Record<string, string> = {
List: 'ListView',
Edit: 'EditView',
Gantt: 'GanttView',
TodoBoard: 'TodoBoard',
CardView: 'CardView',
SchedulerView: 'SchedulerView',
Pivot: 'PivotView',
Chart: 'ChartView',
}
addNode(match.index, platformTypes[view] || view || 'ListView', 'platform', props)
}
for (const match of code.matchAll(/<img\b([^>]*?)\/>/g)) {
addNode(match.index, 'img', 'html', parseGeneratedJsxProps(match[1], componentDefaultProps))
}
const ignoredCustomTags = new Set(['UiKit', 'PlatformViewHost', 'React', 'Fragment'])
for (const match of code.matchAll(/<([A-Z][A-Za-z0-9_$]*)\b([^>]*?)\/>/g)) {
if (ignoredCustomTags.has(match[1]) || match[1].startsWith('UiKit')) continue
const definition = definitions.get(match[1])
addNode(
match.index,
match[1],
definition?.kind || 'custom',
parseGeneratedJsxProps(match[2], componentDefaultProps),
)
}
2026-08-06 13:17:59 +00:00
document.nodes = positionedNodes
.sort((left, right) => left.index - right.index)
.map(({ node }) => node)
2026-08-05 20:51:43 +00:00
return document.nodes.length ? document : null
}
const PropertyEditor = ({
name,
type,
value,
options,
onChange,
}: {
name: string
type: string
value: unknown
options?: string[]
onChange: (value: unknown) => void
}) => {
2026-08-06 13:17:59 +00:00
const [styleModalOpen, setStyleModalOpen] = useState(false)
2026-08-05 20:51:43 +00:00
const inputClass =
'w-full rounded-md border border-slate-300 bg-white px-2.5 py-2 text-xs text-slate-800 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'
if (type === 'boolean') {
return (
<label className="flex cursor-pointer items-center justify-between rounded-md border border-slate-200 px-2.5 py-2 dark:border-slate-700">
<span className="text-xs text-slate-600 dark:text-slate-300">
{value ? 'Enabled' : 'Disabled'}
</span>
<input
checked={Boolean(value)}
className="h-4 w-4 accent-sky-600"
type="checkbox"
onChange={(event) => onChange(event.target.checked)}
/>
</label>
)
}
if (type === 'select' && options) {
return (
<select
className={inputClass}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)
}
if (type === 'object' || type === 'array') {
return (
<textarea
className={`${inputClass} min-h-20 font-mono`}
value={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
onChange={(event) => {
try {
onChange(JSON.parse(event.target.value))
} catch {
onChange(event.target.value)
}
}}
/>
)
}
2026-08-06 13:17:59 +00:00
const normalizedPropertyName = name.toLowerCase()
const isCssProperty = type === 'string' && normalizedPropertyName.includes('css')
const isClassProperty =
type === 'string' &&
(normalizedPropertyName === 'class' ||
normalizedPropertyName.endsWith('class') ||
normalizedPropertyName.endsWith('classname'))
if (isClassProperty || isCssProperty) {
2026-08-05 20:51:43 +00:00
const currentClasses = String(value ?? '')
return (
<>
<div className="flex gap-2">
2026-08-06 13:17:59 +00:00
{isCssProperty ? (
<textarea
className={`${inputClass} min-h-20 font-mono`}
placeholder="CSS kodunu yazın…"
value={currentClasses}
onChange={(event) => onChange(event.target.value)}
/>
) : (
<input
className={`${inputClass} font-mono`}
placeholder="CSS veya Tailwind class adlarını yazın…"
type="text"
value={currentClasses}
onChange={(event) => onChange(event.target.value)}
/>
)}
2026-08-05 20:51:43 +00:00
<Button
icon={<FaBolt />}
size="sm"
2026-08-06 13:17:59 +00:00
title={`${name} stillerini StyleModal ile düzenle`}
2026-08-05 20:51:43 +00:00
variant="default"
2026-08-06 13:17:59 +00:00
onClick={() => setStyleModalOpen(true)}
2026-08-05 20:51:43 +00:00
/>
</div>
2026-08-06 13:17:59 +00:00
<StyleModal
isOpen={styleModalOpen}
mode={isCssProperty ? 'css' : 'class'}
propertyName={name}
value={currentClasses}
onChange={onChange}
onClose={() => setStyleModalOpen(false)}
2026-08-05 20:51:43 +00:00
/>
</>
)
}
const multiline = name === 'children' || name === 'html' || String(value ?? '').length > 80
if (multiline) {
return (
<textarea
className={`${inputClass} min-h-20`}
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
/>
)
}
return (
<input
className={inputClass}
2026-08-06 13:17:59 +00:00
placeholder={type === 'string' ? 'Metin veya ::LanguageKey' : undefined}
2026-08-05 20:51:43 +00:00
type={type === 'number' ? 'number' : name.toLowerCase().includes('color') ? 'color' : 'text'}
value={String(value ?? '')}
onChange={(event) =>
onChange(type === 'number' ? Number(event.target.value) : event.target.value)
}
/>
)
}
const VisualComponentDesigner = () => {
const { id } = useParams()
2026-08-06 13:17:59 +00:00
const { translate } = useLocalization()
2026-08-05 20:51:43 +00:00
const { components, updateComponent, renderComponent } = useComponents()
const [componentDetails, setComponentDetails] = useState<CustomComponent | null>(null)
const [componentLoadError, setComponentLoadError] = useState('')
const [loadedId, setLoadedId] = useState<string | null>(null)
const [document, setDocument] = useState<DesignerDocument>(() => createEmptyDesignerDocument())
const [manualCode, setManualCode] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [workspaceTab, setWorkspaceTab] = useState<WorkspaceTab>('design')
const [inspectorTab, setInspectorTab] = useState<InspectorTab>('properties')
const [search, setSearch] = useState('')
const [expandedToolboxGroups, setExpandedToolboxGroups] = useState<
Record<DesignerComponentDefinition['toolboxGroup'], boolean>
>({ layout: true, html: true, platform: true, data: true, ui: true, custom: true })
const [isSaving, setIsSaving] = useState(false)
const [savedCode, setSavedCode] = useState('')
const [savedDesignerFingerprint, setSavedDesignerFingerprint] = useState('')
const [dataTestResults, setDataTestResults] = useState<Record<string, DataTestResult>>({})
const [dataSourceSamples, setDataSourceSamples] = useState<Record<string, unknown>>({})
const [dataPanelSourceId, setDataPanelSourceId] = useState('')
const [selectDataModes, setSelectDataModes] = useState<Record<string, 'static' | 'endpoint'>>({})
const [staticCollectionDrafts, setStaticCollectionDrafts] = useState<Record<string, string>>({})
const [staticCollectionErrors, setStaticCollectionErrors] = useState<Record<string, string>>({})
const [generatedEndpoints, setGeneratedEndpoints] = useState<CrudEndpoint[]>([])
const [endpointCatalogLoading, setEndpointCatalogLoading] = useState(true)
const [endpointCatalogError, setEndpointCatalogError] = useState('')
const [catalogSourceEditor, setCatalogSourceEditor] = useState<{
item: DataSourceCatalogItem | null
draft: DesignerDataSource
} | null>(null)
const [catalogSourceTestResult, setCatalogSourceTestResult] = useState<DataTestResult | null>(
null,
)
const [endpointResultModal, setEndpointResultModal] = useState<EndpointResultModal | null>(null)
const [isCatalogSourceSaving, setIsCatalogSourceSaving] = useState(false)
const undoStack = useRef<DesignerDocument[]>([])
const redoStack = useRef<DesignerDocument[]>([])
const initialDataSourcesToTestRef = useRef<Set<string>>(new Set())
const component = componentDetails?.id === id ? componentDetails : undefined
const name = component?.name || 'VisualComponent'
const customNames = useMemo(
() => components.filter((item) => item.id !== id && item.isActive).map((item) => item.name),
[components, id],
)
const catalog = useMemo(() => getDesignerCatalog(customNames), [customNames])
const catalogByName = useMemo(() => new Map(catalog.map((item) => [item.name, item])), [catalog])
const selectDesignerNode = useCallback((nodeId: string | null) => {
setSelectedId(nodeId || null)
setInspectorTab('properties')
}, [])
2026-08-05 20:51:43 +00:00
useEffect(() => {
let cancelled = false
setEndpointCatalogLoading(true)
setEndpointCatalogError('')
developerKitService
.getGeneratedListEndpoints()
.then((result) => {
if (!cancelled) setGeneratedEndpoints(result.items || [])
})
.catch((error) => {
if (!cancelled) setEndpointCatalogError(getSaveErrorMessage(error))
})
.finally(() => {
if (!cancelled) setEndpointCatalogLoading(false)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
let cancelled = false
setComponentDetails(null)
setComponentLoadError('')
setLoadedId(null)
setSelectedId(null)
initialDataSourcesToTestRef.current.clear()
if (!id) return () => undefined
const loadComponentDetails = async () => {
try {
const currentComponent = await developerKitService.getCustomComponent(id)
if (!cancelled) setComponentDetails(currentComponent)
} catch (error) {
if (cancelled) return
setComponentLoadError(getSaveErrorMessage(error))
}
}
void loadComponentDetails()
return () => {
cancelled = true
}
}, [id])
useEffect(() => {
if (!id || !component || loadedId === id) return
const propsDocument = parseDesignerDocument(component.props)
const hasStoredDesignerNodes = Boolean(propsDocument?.nodes.length)
const codeBackupDocument = hasStoredDesignerNodes
? null
: parseDesignerDocumentFromCode(component.code)
const migratedCodeDocument =
hasStoredDesignerNodes || codeBackupDocument
? null
: migrateGeneratedCodeToDesignerDocument(component.code, catalogByName)
const visualDocument = hasStoredDesignerNodes
? propsDocument
: codeBackupDocument || migratedCodeDocument || propsDocument
const initialDocument = visualDocument || createEmptyDesignerDocument('code')
initialDataSourcesToTestRef.current = new Set(
initialDocument.dataSources.map((source) => source.id),
)
setDocument(initialDocument)
setDataTestResults({})
setDataSourceSamples({})
setManualCode(component.code || '')
setSavedCode(component.code || '')
setSavedDesignerFingerprint(JSON.stringify(initialDocument))
setWorkspaceTab('design')
setLoadedId(id)
undoStack.current = []
redoStack.current = []
}, [catalogByName, component, id, loadedId])
const generatedCode = useMemo(() => generateDesignerCode(name, document), [document, name])
const effectiveCode = document.sourceMode === 'visual' ? generatedCode : manualCode
const isDirty =
effectiveCode.trim() !== savedCode.trim() ||
JSON.stringify(document) !== savedDesignerFingerprint
const commitDocument = useCallback((updater: (current: DesignerDocument) => DesignerDocument) => {
setDocument((current) => {
undoStack.current.push(clone(current))
if (undoStack.current.length > 80) undoStack.current.shift()
redoStack.current = []
return updater(current)
})
}, [])
const undo = useCallback(() => {
const previous = undoStack.current.pop()
if (!previous) return
redoStack.current.push(clone(document))
setDocument(previous)
}, [document])
const redo = useCallback(() => {
const next = redoStack.current.pop()
if (!next) return
undoStack.current.push(clone(document))
setDocument(next)
}, [document])
const addComponent = useCallback(
(definitionName: string, parentId: string | null = null, slot?: string) => {
2026-08-05 20:51:43 +00:00
const definition = catalogByName.get(definitionName)
if (!definition) return
const node = definitionToNode(definition)
if (slot) node.slot = slot
2026-08-05 20:51:43 +00:00
commitDocument((current) => ({
...current,
sourceMode: 'visual',
nodes: insertNodeTree(current.nodes, node, parentId),
}))
selectDesignerNode(node.id)
2026-08-05 20:51:43 +00:00
setWorkspaceTab('design')
},
[catalogByName, commitDocument, selectDesignerNode],
2026-08-05 20:51:43 +00:00
)
const selectedNode = useMemo(
() => findDesignerNode(document.nodes, selectedId),
[document.nodes, selectedId],
)
const selectedDefinition = selectedNode ? catalogByName.get(selectedNode.type) : undefined
const selectedIsDataComponent = Boolean(
selectedNode && DESIGNER_DATA_COMPONENT_NAMES.has(selectedNode.type),
)
2026-08-05 20:51:43 +00:00
const selectedProperties = useMemo<DesignerPropertyInfo[]>(() => {
if (!selectedNode) return []
const definedProperties = selectedDefinition?.properties || []
const definedNames = new Set(definedProperties.map((property) => property.name))
const storedProperties = Object.entries(selectedNode.props)
.filter(([propertyName]) => !definedNames.has(propertyName))
.map(([propertyName, value]) => ({
name: propertyName,
type: (Array.isArray(value)
? 'array'
: value !== null && typeof value === 'object'
? 'object'
: typeof value === 'boolean'
? 'boolean'
: typeof value === 'number'
? 'number'
: 'string') as DesignerPropertyInfo['type'],
value,
category: 'properties' as const,
}))
const properties = [...definedProperties, ...storedProperties]
if (!properties.some((property) => property.name === 'className')) {
properties.push({
name: 'className',
type: 'string',
value: '',
category: 'styling',
})
}
return properties
}, [selectedDefinition, selectedNode])
const selectedBindingSourceId = selectedNode
? Object.values(selectedNode.bindings || {}).find((binding) =>
document.dataSources.some((source) => source.id === binding.sourceId),
)?.sourceId
: undefined
useEffect(() => {
setDataPanelSourceId((current) => {
if (selectedBindingSourceId) return selectedBindingSourceId
if (document.dataSources.some((source) => source.id === current)) return current
return document.dataSources[0]?.id || ''
})
}, [document.dataSources, selectedBindingSourceId])
const dataSourceCatalog = useMemo<DataSourceCatalogItem[]>(() => {
const items = new Map<string, DataSourceCatalogItem>()
const catalogComponents = componentDetails
? [componentDetails, ...components.filter((storedComponent) => storedComponent.id !== id)]
: components
catalogComponents.forEach((storedComponent) => {
const storedDocument = parseDesignerDocument(storedComponent.props)
storedDocument?.dataSources.forEach((source) => {
const dataSourceKey = `${source.method}:${source.url.trim()}:${source.responsePath.trim()}`
if (items.has(dataSourceKey)) return
const attached = document.dataSources.some(
(currentSource) =>
currentSource.method === source.method &&
currentSource.url.trim() === source.url.trim() &&
currentSource.responsePath.trim() === source.responsePath.trim(),
)
items.set(dataSourceKey, {
attached,
key: `component:${storedComponent.id}:${source.id}`,
origin: `${storedComponent.name} componenti`,
originType: 'component',
ownerComponentId: storedComponent.id,
source,
})
})
})
generatedEndpoints
.filter(
(endpoint) =>
endpoint.isActive && endpoint.method === 'GET' && !endpoint.path.includes('{'),
)
.forEach((endpoint) => {
const dataSourceKey = `GET:${endpoint.path.trim()}:`
if (items.has(dataSourceKey)) return
const attached = document.dataSources.some(
(source) => source.method === 'GET' && source.url.trim() === endpoint.path.trim(),
)
items.set(dataSourceKey, {
attached,
key: `generated:${endpoint.id}`,
origin: 'CRUD Endpoint Manager',
originType: 'crud',
source: {
id: `generated_${endpoint.id}`,
name: `${endpoint.entityName} · ${endpoint.operationType}`,
method: 'GET',
url: endpoint.path,
responsePath: '',
},
})
})
return [...items.values()]
}, [componentDetails, components, document.dataSources, generatedEndpoints, id])
const reusableDataSources = useMemo(() => dataSourceCatalog, [dataSourceCatalog])
const updateSelectedProp = (propertyName: string, value: unknown) => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
props: { ...node.props, [propertyName]: value },
})),
}))
}
const updateNodeProp = useCallback(
(nodeId: string, propertyName: string, value: unknown) => {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, nodeId, (node) => ({
...node,
props: { ...node.props, [propertyName]: value },
})),
}))
},
[commitDocument],
)
2026-08-05 20:51:43 +00:00
const updateNodeEvent = (nodeId: string, eventName: string, value: string) => {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, nodeId, (node) => ({
...node,
events: { ...node.events, [eventName]: value },
})),
}))
}
const updateSelectedBinding = (propertyName: string, sourceId: string, path = '') => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
if (sourceId) bindings[propertyName] = { sourceId, path }
else delete bindings[propertyName]
return { ...node, bindings }
}),
}))
}
const updateSelectedBindingDetails = (
propertyName: string,
details: Partial<DesignerBinding>,
) => {
if (!selectedId) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const binding = node.bindings?.[propertyName]
if (!binding) return node
return {
...node,
bindings: {
...node.bindings,
[propertyName]: { ...binding, ...details },
},
}
}),
}))
}
const addDataSource = () => {
const dataSource: DesignerDataSource = {
id: `source_${createDesignerId().slice(-8)}`,
name: `Endpoint ${document.dataSources.length + 1}`,
method: 'GET',
url: '',
responsePath: '',
}
setCatalogSourceEditor({ item: null, draft: dataSource })
setCatalogSourceTestResult(null)
}
const addReusableDataSource = (catalogItem: DataSourceCatalogItem) => {
const existingSource = document.dataSources.find(
(source) =>
source.method === catalogItem.source.method &&
source.url.trim() === catalogItem.source.url.trim() &&
source.responsePath.trim() === catalogItem.source.responsePath.trim(),
)
if (existingSource) {
if (selectedId && isTabularDataComponent(selectedNode?.type)) {
updateSelectedBinding('items', existingSource.id, '')
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
} else if (selectedId && isOptionDataComponent(selectedNode?.type)) {
updateSelectedBinding(getOptionDataProperty(selectedNode?.type), existingSource.id, '')
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
}
setDataPanelSourceId(existingSource.id)
return
}
const dataSource: DesignerDataSource = {
...catalogItem.source,
id: `source_${createDesignerId().slice(-8)}`,
}
commitDocument((current) => ({
...current,
dataSources: [...current.dataSources, dataSource],
nodes:
selectedId &&
(isTabularDataComponent(selectedNode?.type) || isOptionDataComponent(selectedNode?.type))
? updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
bindings: {
...node.bindings,
[isTabularDataComponent(selectedNode?.type)
? 'items'
: getOptionDataProperty(selectedNode?.type)]: {
sourceId: dataSource.id,
path: '',
},
},
}))
: current.nodes,
}))
if (
selectedId &&
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type))
) {
setSelectDataModes((current) => ({ ...current, [selectedId]: 'endpoint' }))
}
setDataPanelSourceId(dataSource.id)
}
const testDataSource = useCallback(async (source: DesignerDataSource, showResult = false) => {
if (showResult) setEndpointResultModal(null)
setDataTestResults((current) => ({
...current,
[source.id]: { status: 'loading', message: 'İstek gönderiliyor…' },
}))
try {
if (!source.url.trim()) {
throw new Error('Endpoint URL alanını doldurun.')
}
if (!source.url.trim().startsWith('/api/')) {
throw new Error('Platform endpoint adresi /api/ ile başlamalıdır.')
}
if (!isRunnableDataSourceUrl(source.url)) {
throw new Error('Çağrılabilir bir endpoint URL girin.')
}
const response = await apiService.fetchData({ method: source.method, url: source.url.trim() })
const result = resolveDesignerResponse(response.data, source.responsePath)
if (source.responsePath.trim() && result === undefined) {
throw new Error(`Response path bulunamadı: ${source.responsePath}`)
}
const message =
typeof result === 'string' ? result : (JSON.stringify(result, null, 2) ?? String(result))
setDataTestResults((current) => ({
...current,
[source.id]: { status: 'success', message },
}))
setDataSourceSamples((current) => ({ ...current, [source.id]: result }))
if (showResult) setEndpointResultModal({ source, result })
} catch (error) {
setDataSourceSamples((current) => {
const next = { ...current }
delete next[source.id]
return next
})
setDataTestResults((current) => ({
...current,
[source.id]: { status: 'error', message: getSaveErrorMessage(error) },
}))
}
}, [])
const persistCatalogOwnerDocument = useCallback(
async (item: DataSourceCatalogItem, nextDocument: DesignerDocument) => {
if (item.originType !== 'component' || !item.ownerComponentId) {
throw new Error('CRUD Endpoint Manager kaynakları salt okunurdur.')
}
const owner =
componentDetails?.id === item.ownerComponentId
? componentDetails
: components.find((storedComponent) => storedComponent.id === item.ownerComponentId)
if (!owner) throw new Error('Endpointin bağlı olduğu Custom Component bulunamadı.')
let existingProps: Record<string, unknown> = {}
try {
const parsedProps = owner.props ? JSON.parse(owner.props) : {}
if (parsedProps && typeof parsedProps === 'object' && !Array.isArray(parsedProps)) {
existingProps = parsedProps
}
} catch {
existingProps = {}
}
const update = {
name: owner.name,
routePath: owner.routePath,
description: owner.description,
dependencies: owner.dependencies,
code:
nextDocument.sourceMode === 'visual'
? generateDesignerCode(owner.name, nextDocument)
: owner.code,
props: JSON.stringify({ ...existingProps, visualDesigner: nextDocument }),
isActive: owner.isActive,
}
await updateComponent(owner.id, update)
return update
},
[componentDetails, components, updateComponent],
)
const openCatalogSourceEditor = (item: DataSourceCatalogItem) => {
if (item.originType !== 'component') return
setCatalogSourceEditor({ item, draft: { ...item.source } })
setCatalogSourceTestResult(null)
}
const updateCatalogSourceDraft = (updates: Partial<DesignerDataSource>) => {
const sourceId = catalogSourceEditor?.draft.id
if (sourceId) {
setDataSourceSamples((samples) => {
const next = { ...samples }
delete next[sourceId]
return next
})
}
setCatalogSourceEditor((current) =>
current ? { ...current, draft: { ...current.draft, ...updates } } : current,
)
setCatalogSourceTestResult(null)
}
const testCatalogSource = async () => {
if (!catalogSourceEditor) return
const source = catalogSourceEditor.draft
setCatalogSourceTestResult({ status: 'loading', message: 'İstek gönderiliyor…' })
try {
if (!source.name.trim()) throw new Error('Endpoint adı boş olamaz.')
if (!isRunnableDataSourceUrl(source.url)) {
throw new Error('Çağrılabilir ve /api/ ile başlayan bir endpoint URL girin.')
}
const response = await apiService.fetchData({ method: source.method, url: source.url.trim() })
const result = resolveDesignerResponse(response.data, source.responsePath)
if (source.responsePath.trim() && result === undefined) {
throw new Error(`Response path bulunamadı: ${source.responsePath}`)
}
setCatalogSourceTestResult({
status: 'success',
message:
typeof result === 'string' ? result : (JSON.stringify(result, null, 2) ?? String(result)),
})
setDataSourceSamples((current) => ({ ...current, [source.id]: result }))
setEndpointResultModal({ source, result })
} catch (error) {
setDataSourceSamples((current) => {
const next = { ...current }
delete next[source.id]
return next
})
setCatalogSourceTestResult({ status: 'error', message: getSaveErrorMessage(error) })
}
}
const saveCatalogSource = async () => {
if (!catalogSourceEditor) return
const { item, draft } = catalogSourceEditor
if (!draft.name.trim()) {
setCatalogSourceTestResult({ status: 'error', message: 'Endpoint adı boş olamaz.' })
return
}
if (!isRunnableDataSourceUrl(draft.url)) {
setCatalogSourceTestResult({
status: 'error',
message: 'Çağrılabilir ve /api/ ile başlayan bir endpoint URL girin.',
})
return
}
if (catalogSourceTestResult?.status !== 'success') {
setCatalogSourceTestResult({
status: 'error',
message: 'Kaydetmeden önce endpointi başarıyla çalıştırın.',
})
return
}
if (!item) {
if (!id || !componentDetails) return
setIsCatalogSourceSaving(true)
try {
const storedDocument = parseDesignerDocument(componentDetails.props)
const baseDocument = storedDocument || { ...document, dataSources: [] }
const nextDocument: DesignerDocument = {
...baseDocument,
dataSources: [
...baseDocument.dataSources.filter((source) => source.id !== draft.id),
draft,
],
}
const catalogItem: DataSourceCatalogItem = {
attached: true,
key: `component:${id}:${draft.id}`,
origin: `${componentDetails.name} componenti`,
originType: 'component',
ownerComponentId: id,
source: draft,
}
const update = await persistCatalogOwnerDocument(catalogItem, nextDocument)
const localDocument: DesignerDocument = {
...document,
dataSources: [...document.dataSources.filter((source) => source.id !== draft.id), draft],
}
setDocument(localDocument)
setDataPanelSourceId(draft.id)
setComponentDetails((current) => {
if (!current || current.id !== id) return current
return { ...current, ...update, lastModificationTime: new Date().toISOString() }
})
setSavedCode(update.code)
setSavedDesignerFingerprint(JSON.stringify(nextDocument))
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
toast.push(
<Notification type="success" duration={2400}>
Endpoint eklendi ve kullanılabilir kaynaklara kaydedildi.
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
setCatalogSourceTestResult({ status: 'error', message: getSaveErrorMessage(error) })
} finally {
setIsCatalogSourceSaving(false)
}
return
}
const owner =
componentDetails?.id === item.ownerComponentId
? componentDetails
: components.find((storedComponent) => storedComponent.id === item.ownerComponentId)
const ownerDocument = parseDesignerDocument(owner?.props)
if (!ownerDocument) {
setCatalogSourceTestResult({
status: 'error',
message: 'Custom Component tasarım şeması okunamadı.',
})
return
}
setIsCatalogSourceSaving(true)
try {
const nextDocument = {
...ownerDocument,
dataSources: ownerDocument.dataSources.map((source) =>
source.id === item.source.id ? { ...draft, id: source.id } : source,
),
}
const update = await persistCatalogOwnerDocument(item, nextDocument)
if (item.ownerComponentId === id) {
setDocument((current) => ({
...current,
dataSources: current.dataSources.map((source) =>
source.id === item.source.id ? { ...draft, id: source.id } : source,
),
}))
setComponentDetails((current) => {
if (!current || current.id !== id) return current
return { ...current, ...update, lastModificationTime: new Date().toISOString() }
})
setSavedCode(update.code)
setSavedDesignerFingerprint(JSON.stringify(nextDocument))
}
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
toast.push(
<Notification type="success" duration={2400}>
Endpoint kaynağı güncellendi.
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
setCatalogSourceTestResult({ status: 'error', message: getSaveErrorMessage(error) })
} finally {
setIsCatalogSourceSaving(false)
}
}
const deleteCatalogSource = async (item: DataSourceCatalogItem) => {
if (item.originType !== 'component') return
const owner =
componentDetails?.id === item.ownerComponentId
? componentDetails
: components.find((storedComponent) => storedComponent.id === item.ownerComponentId)
const ownerDocument =
item.ownerComponentId === id ? document : parseDesignerDocument(owner?.props)
if (!ownerDocument) return
if (!window.confirm(`"${item.source.name}" endpoint kaynağı silinsin mi?`)) return
try {
const nextDocument = {
...ownerDocument,
dataSources: ownerDocument.dataSources.filter((source) => source.id !== item.source.id),
nodes: removeDataSourceBindings(ownerDocument.nodes, item.source.id),
}
const update = await persistCatalogOwnerDocument(item, nextDocument)
if (item.ownerComponentId === id) {
setDocument(nextDocument)
setComponentDetails((current) => {
if (!current || current.id !== id) return current
return { ...current, ...update, lastModificationTime: new Date().toISOString() }
})
setSavedCode(update.code)
setSavedDesignerFingerprint(JSON.stringify(nextDocument))
}
toast.push(
<Notification type="success" duration={2400}>
Endpoint kaynağı ve bağlı property bağlantıları silindi.
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
toast.push(
<Notification type="danger" duration={4000}>
{getSaveErrorMessage(error)}
</Notification>,
{ placement: 'bottom-end' },
)
}
}
useEffect(() => {
if (!id || loadedId !== id) return
document.dataSources.forEach((source) => {
if (!initialDataSourcesToTestRef.current.has(source.id)) return
initialDataSourcesToTestRef.current.delete(source.id)
if (!isRunnableDataSourceUrl(source.url)) return
void testDataSource(source)
})
}, [document.dataSources, id, loadedId, testDataSource])
const deleteNode = useCallback(
(nodeId: string) => {
commitDocument((current) => ({ ...current, nodes: removeNodeTree(current.nodes, nodeId) }))
setSelectedId((current) => (current === nodeId ? null : current))
},
[commitDocument],
)
const duplicateNode = useCallback(
(nodeId: string) => {
commitDocument((current) => ({ ...current, nodes: duplicateInTree(current.nodes, nodeId) }))
},
[commitDocument],
)
const moveNode = useCallback(
(nodeId: string, direction: -1 | 1) => {
commitDocument((current) => ({
...current,
nodes: moveInTree(current.nodes, nodeId, direction),
}))
},
[commitDocument],
)
2026-08-06 13:17:59 +00:00
const reorderNode = useCallback(
(sourceId: string, targetId: string, placement: 'before' | 'after') => {
commitDocument((current) => ({
...current,
nodes: reorderNodeTree(current.nodes, sourceId, targetId, placement),
}))
selectDesignerNode(sourceId)
},
[commitDocument, selectDesignerNode],
)
2026-08-05 20:51:43 +00:00
const collectDependencies = useCallback(() => {
const names = new Set<string>()
walkDesignerNodes(document.nodes, (node) => {
if (node.kind === 'custom') names.add(node.type)
})
return [...names]
}, [document.nodes])
const handleSave = useCallback(
async (successMessage = 'Bileşen ve tasarım şeması kaydedildi.') => {
if (!id || !component) return
setIsSaving(true)
try {
const invalidDataSource = document.dataSources.find(
(source) => source.url.trim() && !source.url.trim().startsWith('/api/'),
)
if (invalidDataSource) {
throw new Error(
`${invalidDataSource.name}: Platform endpoint adresi /api/ ile başlamalıdır.`,
)
}
let existingProps: Record<string, unknown> = {}
try {
const parsed = component.props ? JSON.parse(component.props) : {}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) existingProps = parsed
} catch {
existingProps = {}
}
const codeToSave = document.sourceMode === 'visual' ? generatedCode : manualCode
const dependenciesToSave = JSON.stringify(
document.sourceMode === 'visual'
? collectDependencies()
: (() => {
try {
return JSON.parse(component.dependencies || '[]')
} catch {
return []
}
})(),
)
const propsToSave =
document.sourceMode === 'visual'
? JSON.stringify({ ...existingProps, visualDesigner: document })
: component.props
const update = {
name: component.name,
routePath: component.routePath,
description: component.description,
dependencies: dependenciesToSave,
code: codeToSave.trim(),
props: propsToSave,
isActive: component.isActive,
}
await updateComponent(id, update)
setComponentDetails((current) =>
current?.id === id
? { ...current, ...update, lastModificationTime: new Date().toISOString() }
: current,
)
setSavedCode(codeToSave)
setSavedDesignerFingerprint(JSON.stringify(document))
toast.push(
<Notification type="success" duration={2400}>
{successMessage}
</Notification>,
{ placement: 'bottom-end' },
)
} catch (error) {
console.error('Visual component save failed:', error)
const errorMessage = getSaveErrorMessage(error)
toast.push(
<Notification type="danger" duration={4000}>
{errorMessage}
</Notification>,
{ placement: 'bottom-end' },
)
} finally {
setIsSaving(false)
}
},
[collectDependencies, component, document, generatedCode, id, manualCode, updateComponent],
)
useEffect(() => {
const keyHandler = (event: KeyboardEvent) => {
2026-08-06 13:17:59 +00:00
const target = event.target
const isPropertyEditing =
target instanceof HTMLElement &&
Boolean(
target.closest(
'[data-property-panel], input, textarea, select, [contenteditable="true"], [role="textbox"], .monaco-editor',
),
)
2026-08-05 20:51:43 +00:00
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
void handleSave()
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.preventDefault()
if (event.shiftKey) redo()
else undo()
}
2026-08-06 13:17:59 +00:00
if (
event.key === 'Delete' &&
!isPropertyEditing &&
selectedId &&
workspaceTab === 'design'
) {
deleteNode(selectedId)
}
2026-08-05 20:51:43 +00:00
}
window.addEventListener('keydown', keyHandler)
return () => window.removeEventListener('keydown', keyHandler)
}, [deleteNode, handleSave, redo, selectedId, undo, workspaceTab])
const filteredCatalog = useMemo(() => {
const term = search.trim().toLocaleLowerCase('tr')
return catalog.filter(
(item) =>
!term ||
`${item.name} ${item.description} ${item.category}`.toLocaleLowerCase('tr').includes(term),
)
}, [catalog, search])
const groupedCatalog = useMemo(() => {
const groups = new Map<
DesignerComponentDefinition['toolboxGroup'],
DesignerComponentDefinition[]
>(TOOLBOX_GROUP_ORDER.map((group) => [group, []]))
filteredCatalog.forEach((item) => {
groups.get(item.toolboxGroup)?.push(item)
})
return TOOLBOX_GROUP_ORDER.map(
(group) =>
[
group,
[...(groups.get(group) || [])].sort((left, right) =>
left.name.localeCompare(right.name, 'tr', { sensitivity: 'base' }),
),
] as const,
).filter(([, definitions]) => definitions.length > 0)
2026-08-05 20:51:43 +00:00
}, [filteredCatalog])
const activeDataSource = document.dataSources.find((source) => source.id === dataPanelSourceId)
const activeDataSample = activeDataSource ? dataSourceSamples[activeDataSource.id] : undefined
const optionDataProperty = getOptionDataProperty(selectedNode?.type)
const selectOptionsBinding = isOptionDataComponent(selectedNode?.type)
? selectedNode?.bindings?.[optionDataProperty]
: undefined
const tabularItemsBinding = isTabularDataComponent(selectedNode?.type)
? selectedNode?.bindings?.items
: undefined
const selectDataMode =
selectedId &&
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type))
? selectDataModes[selectedId] ||
(selectOptionsBinding?.sourceId || tabularItemsBinding?.sourceId ? 'endpoint' : 'static')
: 'static'
const staticSelectOptions: StaticSelectOption[] =
isOptionDataComponent(selectedNode?.type) &&
Array.isArray(selectedNode?.props[optionDataProperty])
? (selectedNode.props[optionDataProperty] as unknown[]).map((option, index) => {
if (option && typeof option === 'object' && !Array.isArray(option)) {
const record = option as Record<string, unknown>
return {
label: String(
record.label ?? record.children ?? record.title ?? `Seçenek ${index + 1}`,
),
value: record.value ?? record.eventKey ?? record.key ?? index + 1,
2026-08-05 20:51:43 +00:00
}
}
return { label: String(option ?? ''), value: option ?? index + 1 }
})
: []
const selectCollectionPaths = useMemo(() => {
if (activeDataSample === undefined) return []
const paths = discoverDataFields(activeDataSample)
.filter((field) => field.type === 'array')
.map((field) => field.path)
return Array.isArray(activeDataSample) ? ['', ...paths] : paths
}, [activeDataSample])
const tabularCollectionPaths = useMemo(() => {
if (activeDataSample === undefined) return []
const paths = discoverDataFields(activeDataSample)
.filter((field) => field.type === 'array')
.map((field) => field.path)
return Array.isArray(activeDataSample) ? ['', ...paths] : paths
}, [activeDataSample])
const selectCollectionSample = useMemo(() => {
if (activeDataSample === undefined) return undefined
const path = selectOptionsBinding?.path || ''
const value = getDesignerValueByPath(activeDataSample, path)
return Array.isArray(value) ? value : undefined
}, [activeDataSample, selectOptionsBinding?.path])
const selectColumnFields = useMemo(
() =>
selectCollectionSample === undefined
? []
: discoverDataFields(selectCollectionSample).filter(
(field) => !['array', 'object'].includes(field.type),
),
[selectCollectionSample],
)
const setSelectDataMode = (mode: 'static' | 'endpoint') => {
if (
!selectedId ||
(!isOptionDataComponent(selectedNode?.type) && !isTabularDataComponent(selectedNode?.type))
)
return
setSelectDataModes((current) => ({ ...current, [selectedId]: mode }))
if (isTabularDataComponent(selectedNode?.type)) {
const source = activeDataSource || document.dataSources[0]
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
const props = { ...node.props }
delete props.dataColumns
if (mode === 'endpoint' && source) {
bindings.items = { sourceId: source.id, path: '' }
} else {
delete bindings.items
}
return { ...node, bindings, props }
}),
}))
if (mode === 'static') {
const staticItems = Array.isArray(selectedNode?.props.items) ? selectedNode.props.items : []
setStaticCollectionDrafts((current) => ({
...current,
[selectedId]: JSON.stringify(staticItems, null, 2),
}))
setStaticCollectionErrors((current) => ({ ...current, [selectedId]: '' }))
} else if (source && dataSourceSamples[source.id] === undefined) {
void testDataSource(source)
}
return
}
if (mode === 'static') {
updateSelectedBinding(optionDataProperty, '')
return
}
const source = activeDataSource || document.dataSources[0]
if (source) {
updateSelectedBinding(optionDataProperty, source.id, '')
if (dataSourceSamples[source.id] === undefined) void testDataSource(source)
}
}
const updateStaticSelectOptions = (options: StaticSelectOption[]) => {
updateSelectedBinding(optionDataProperty, '')
updateSelectedProp(optionDataProperty, options)
if (selectedId) {
setSelectDataModes((current) => ({ ...current, [selectedId]: 'static' }))
}
}
const updateStaticCollection = (rawValue: string) => {
if (!selectedId || !isTabularDataComponent(selectedNode?.type)) return
setStaticCollectionDrafts((current) => ({ ...current, [selectedId]: rawValue }))
try {
const value = JSON.parse(rawValue)
if (!Array.isArray(value)) throw new Error('Statik veri bir JSON array olmalıdır.')
setStaticCollectionErrors((current) => ({ ...current, [selectedId]: '' }))
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const bindings = { ...(node.bindings || {}) }
delete bindings.items
const previousFields = discoverDataFields(node.props.items)
.filter((field) => !['array', 'object'].includes(field.type))
.map((field) => field.path)
const nextFields = discoverDataFields(value)
.filter((field) => !['array', 'object'].includes(field.type))
.map((field) => field.path)
const props: Record<string, unknown> = { ...node.props, items: value }
if (previousFields.join('|') !== nextFields.join('|')) delete props.dataColumns
return { ...node, bindings, props }
}),
}))
setSelectDataModes((current) => ({ ...current, [selectedId]: 'static' }))
} catch (error) {
setStaticCollectionErrors((current) => ({
...current,
[selectedId]: error instanceof Error ? error.message : 'Geçersiz JSON.',
}))
}
}
const selectedAncestors = useMemo(
() => findDesignerAncestors(document.nodes, selectedId) || [],
[document.nodes, selectedId],
)
const repeatedGridAncestor = [...selectedAncestors]
.reverse()
.find((node) => node.type === 'Grid' && node.bindings?.items?.sourceId)
const isActiveRepeatedSource =
repeatedGridAncestor?.bindings.items.sourceId === activeDataSource?.id
const activeBindingSample =
isTabularDataComponent(selectedNode?.type) && selectDataMode === 'static'
? selectedNode?.props.items
: isTabularDataComponent(selectedNode?.type) &&
tabularItemsBinding?.sourceId === activeDataSource?.id &&
activeDataSample !== undefined
? getDesignerValueByPath(activeDataSample, tabularItemsBinding?.path || '')
: activeDataSample !== undefined && isActiveRepeatedSource
? getDesignerValueByPath(
activeDataSample,
repeatedGridAncestor?.bindings.items.path || '',
)
: activeDataSample
const activeDataFields = useMemo(
() => (activeBindingSample === undefined ? [] : discoverDataFields(activeBindingSample)),
[activeBindingSample],
)
const gridColumnFields = useMemo(
() => activeDataFields.filter((field) => !['array', 'object'].includes(field.type)),
[activeDataFields],
)
const configuredGridColumns =
isTabularDataComponent(selectedNode?.type) && Array.isArray(selectedNode?.props.dataColumns)
? selectedNode.props.dataColumns.filter(
(column): column is string => typeof column === 'string',
)
: null
const selectedGridColumns = configuredGridColumns
? configuredGridColumns.filter((column) =>
gridColumnFields.some((field) => field.path === column),
)
: gridColumnFields.map((field) => field.path)
const allGridColumnsSelected =
gridColumnFields.length > 0 && selectedGridColumns.length === gridColumnFields.length
const toggleGridColumn = (columnPath: string) => {
const nextColumns = selectedGridColumns.includes(columnPath)
? selectedGridColumns.filter((column) => column !== columnPath)
: [...selectedGridColumns, columnPath]
updateSelectedProp('dataColumns', nextColumns)
}
const toggleAllGridColumns = () => {
updateSelectedProp(
'dataColumns',
allGridColumnsSelected ? [] : gridColumnFields.map((field) => field.path),
)
}
useEffect(() => {
if (
!selectedId ||
!isTabularDataComponent(selectedNode?.type) ||
configuredGridColumns !== null ||
!gridColumnFields.length
) {
return
}
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
props: { ...node.props, dataColumns: gridColumnFields.map((field) => field.path) },
})),
}))
}, [commitDocument, configuredGridColumns, gridColumnFields, selectedId, selectedNode?.type])
useEffect(() => {
const itemsBinding = isTabularDataComponent(selectedNode?.type)
? selectedNode?.bindings?.items
: undefined
if (
!selectedId ||
!itemsBinding?.sourceId ||
itemsBinding.sourceId !== activeDataSource?.id ||
activeDataSample === undefined
) {
return
}
const currentValue = getDesignerValueByPath(activeDataSample, itemsBinding.path)
if (Array.isArray(currentValue)) return
const collectionPath = Array.isArray(activeDataSample)
? ''
: discoverDataFields(activeDataSample).find((field) => field.type === 'array')?.path
if (collectionPath === undefined || collectionPath === itemsBinding.path) return
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => ({
...node,
bindings: {
...node.bindings,
items: { sourceId: itemsBinding.sourceId, path: collectionPath },
},
})),
}))
}, [activeDataSample, activeDataSource?.id, commitDocument, selectedId, selectedNode])
const dataBindableProperties =
2026-08-06 13:17:59 +00:00
selectedNode && selectedDefinition && DESIGNER_DATA_COMPONENT_NAMES.has(selectedNode.type)
2026-08-05 20:51:43 +00:00
? selectedDefinition.properties.filter(
(property) =>
property.category !== 'events' &&
property.type !== 'function' &&
(isTabularDataComponent(selectedNode.type)
? property.name === 'items'
: isOptionDataComponent(selectedNode.type)
? property.name === getOptionDataProperty(selectedNode.type)
: DATA_BINDABLE_PROPERTY_NAMES.has(property.name) ||
property.type === 'array' ||
['number', 'boolean', 'string', 'select'].includes(property.type)),
2026-08-05 20:51:43 +00:00
)
: []
const isCollectionProperty = (propertyName: string, propertyType: string) =>
propertyType === 'array' || ['items', 'data', 'dataSource', 'options'].includes(propertyName)
const getBindingChoice = (propertyName: string) => {
const binding = selectedNode?.bindings?.[propertyName]
if (!binding || binding.sourceId !== activeDataSource?.id) return ''
const path = binding.path.trim()
if (!path || path === '$item' || path === '0') return '__root__'
if (isActiveRepeatedSource && path.startsWith('$item.')) return path.slice(6)
if (Array.isArray(activeBindingSample) && path.startsWith('0.')) return path.slice(2)
return path
}
const getBindingPath = (fieldPath: string, collection: boolean) => {
if (fieldPath === '__root__') {
if (isActiveRepeatedSource) return '$item'
if (Array.isArray(activeBindingSample) && !collection) return '0'
return ''
}
if (isActiveRepeatedSource) return `$item.${fieldPath}`
if (Array.isArray(activeBindingSample)) return `0.${fieldPath}`
return fieldPath
}
const renderReusableDataSources = (compact = false) => {
const visibleSources = reusableDataSources
return (
<div className="space-y-2">
{endpointCatalogLoading && !visibleSources.length ? (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-center text-xs text-slate-500 dark:border-slate-700">
Kayıtlı endpointler yükleniyor
</p>
) : visibleSources.length ? (
visibleSources.map((item) => (
<div
key={item.key}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-white p-2.5 dark:border-slate-800 dark:bg-slate-900"
>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-slate-700 dark:text-slate-200">
{item.source.name}
</div>
<div className="truncate font-mono text-[9px] text-sky-600">
{item.source.method} {item.source.url}
</div>
{!compact && (
<div className="flex items-center gap-1.5 text-[9px] text-slate-400">
<span>{item.origin}</span>
{item.attached && (
<span className="rounded bg-emerald-100 px-1 py-0.5 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
Ekli
</span>
)}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded-md bg-sky-600 px-2.5 py-1.5 text-[10px] font-semibold text-white hover:bg-sky-700"
type="button"
onClick={() => addReusableDataSource(item)}
>
Kullan
</button>
{item.originType === 'component' && (
<>
<button
className="rounded-md border border-slate-200 p-1.5 text-slate-500 hover:border-amber-400 hover:text-amber-600 dark:border-slate-700"
title="Custom Component endpointini düzenle"
type="button"
onClick={() => openCatalogSourceEditor(item)}
>
<FaEdit />
</button>
<button
className="rounded-md border border-slate-200 p-1.5 text-slate-500 hover:border-red-400 hover:text-red-600 dark:border-slate-700"
title="Custom Component endpointini sil"
type="button"
onClick={() => void deleteCatalogSource(item)}
>
<FaTrash />
</button>
</>
)}
</div>
</div>
))
) : (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-center text-xs leading-5 text-slate-500 dark:border-slate-700">
Bu componente eklenebilecek başka bir GET endpointi bulunmuyor.
</p>
)}
{endpointCatalogError && (
<p className="rounded bg-amber-50 p-2 text-[10px] leading-4 text-amber-700 dark:bg-amber-950 dark:text-amber-200">
CRUD endpoint kataloğu okunamadı: {endpointCatalogError}
</p>
)}
</div>
)
}
const renderSelectDataModeSelector = () => {
if (!isOptionDataComponent(selectedNode?.type) && !isTabularDataComponent(selectedNode?.type))
return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Veri kaynağı türü
</div>
<div className="grid grid-cols-2 gap-1 rounded-md bg-slate-100 p-1 dark:bg-slate-950">
{(['static', 'endpoint'] as const).map((mode) => (
<button
key={mode}
className={`rounded px-2 py-1.5 text-[10px] font-semibold ${
selectDataMode === mode
? 'bg-white text-sky-700 shadow-sm dark:bg-slate-800 dark:text-sky-300'
: 'text-slate-500'
}`}
type="button"
onClick={() => setSelectDataMode(mode)}
>
{mode === 'static'
? isOptionDataComponent(selectedNode?.type)
? 'Statik seçenekler'
: 'Statik veri'
: 'Endpoint'}
</button>
))}
</div>
</div>
)
}
const renderSelectDataConfiguration = () => {
if (!isOptionDataComponent(selectedNode?.type)) return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
{selectDataMode === 'static' ? (
<div className="space-y-2">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Statik seçenekler
</div>
{staticSelectOptions.map((option, index) => (
<div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-1">
<input
aria-label={`Seçenek ${index + 1} başlığı`}
className="min-w-0 rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder="Başlık"
value={option.label}
onChange={(event) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index ? { ...current, label: event.target.value } : current,
),
)
}
/>
<input
aria-label={`Seçenek ${index + 1} değeri`}
className="min-w-0 rounded border border-slate-300 bg-white px-2 py-1.5 text-[10px] dark:border-slate-700 dark:bg-slate-900"
placeholder="Değer"
value={String(option.value ?? '')}
onChange={(event) =>
updateStaticSelectOptions(
staticSelectOptions.map((current, optionIndex) =>
optionIndex === index ? { ...current, value: event.target.value } : current,
),
)
}
/>
<button
aria-label={`Seçenek ${index + 1} sil`}
className="rounded px-2 text-slate-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950"
type="button"
onClick={() =>
updateStaticSelectOptions(
staticSelectOptions.filter((_, optionIndex) => optionIndex !== index),
)
}
>
<FaTrash />
</button>
</div>
))}
<button
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-slate-300 px-2 py-1.5 text-[10px] font-semibold text-sky-600 hover:border-sky-400 dark:border-slate-700"
type="button"
onClick={() =>
updateStaticSelectOptions([
...staticSelectOptions,
{
label: `Seçenek ${staticSelectOptions.length + 1}`,
value: String(staticSelectOptions.length + 1),
},
])
}
>
<FaPlus /> Statik seçenek ekle
</button>
</div>
) : (
<div className="space-y-2">
<div className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
2. Label ve Value eşlemesi
</div>
<p className="text-[10px] leading-4 text-slate-500">
Ekranda görünecek label sütununu ve kaydedilecek value sütununu seçin.
</p>
{activeDataSample !== undefined && selectOptionsBinding?.sourceId && (
<>
<label className="block">
<span className="mb-1 block text-[10px] font-semibold text-slate-500">
Koleksiyon
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={selectOptionsBinding.path}
onChange={(event) =>
updateSelectedBindingDetails(optionDataProperty, {
path: event.target.value,
labelPath: '',
valuePath: '',
})
}
>
{!selectCollectionPaths.length && (
<option value="">Koleksiyon bulunamadı</option>
)}
{selectCollectionPaths.map((path) => (
<option key={path || '__root__'} value={path}>
{path || 'Cevabın tamamı (array)'}
</option>
))}
</select>
</label>
{selectCollectionSample && (
<div className="grid grid-cols-2 gap-2">
{(
[
['labelPath', 'Label sütunu'],
['valuePath', 'Value sütunu'],
] as const
).map(([key, label]) => (
<label key={key} className="block">
<span className="mb-1 block text-[10px] font-semibold text-slate-500">
{label}
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={selectOptionsBinding[key] || ''}
onChange={(event) =>
updateSelectedBindingDetails(optionDataProperty, {
[key]: event.target.value,
})
}
>
<option value="">
{selectColumnFields.length ? 'Sütun seçin' : 'Öğenin kendisi'}
</option>
{selectColumnFields.map((field) => (
<option key={field.path} value={field.path}>
{field.path}
</option>
))}
</select>
</label>
))}
</div>
)}
{selectCollectionSample &&
selectColumnFields.length > 0 &&
(!selectOptionsBinding.labelPath || !selectOptionsBinding.valuePath) && (
<p className="rounded bg-amber-50 p-2 text-[10px] text-amber-700 dark:bg-amber-950 dark:text-amber-200">
{selectedNode?.type} bileşeninin doğru çalışması için label ve value
sütunlarını seçin.
</p>
)}
</>
)}
{activeDataSample === undefined && (
<p className="rounded bg-slate-50 p-2 text-[10px] leading-4 text-slate-500 dark:bg-slate-950">
Önce yukarıdan bir endpoint seçin ve alanların okunmasını bekleyin.
</p>
)}
</div>
)}
</div>
)
}
const renderTabularDataConfiguration = () => {
if (!selectedId || !isTabularDataComponent(selectedNode?.type) || selectDataMode !== 'static')
return null
const currentItems = Array.isArray(selectedNode?.props.items) ? selectedNode.props.items : []
const draft = staticCollectionDrafts[selectedId] ?? JSON.stringify(currentItems, null, 2)
const error = staticCollectionErrors[selectedId]
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Statik satırlar (JSON)
</div>
<p className="mb-2 text-[10px] leading-4 text-slate-500">
Her obje bir satırı, obje alanları da seçilebilir sütunları oluşturur.
</p>
<textarea
className="min-h-40 w-full rounded-md border border-slate-300 bg-white p-2 font-mono text-[10px] leading-4 dark:border-slate-700 dark:bg-slate-900"
spellCheck={false}
value={draft}
onChange={(event) => updateStaticCollection(event.target.value)}
/>
{error && (
<p className="mt-2 rounded bg-red-50 p-2 text-[10px] text-red-600 dark:bg-red-950">
{error}
</p>
)}
{!error && (
<p className="mt-2 text-[10px] text-emerald-600">{currentItems.length} satır hazır.</p>
)}
</div>
)
}
const renderTabularColumnConfiguration = () => {
if (!isTabularDataComponent(selectedNode?.type)) return null
return (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Gösterilecek sütunlar
</span>
<button
className="text-[9px] font-semibold text-sky-600"
type="button"
onClick={toggleAllGridColumns}
>
{allGridColumnsSelected ? 'Tümünü kaldır' : 'Tümünü seç'}
</button>
</div>
{gridColumnFields.length ? (
<div className="max-h-44 space-y-1 overflow-y-auto">
{gridColumnFields.map((field) => (
<label
key={field.path}
className="flex cursor-pointer items-center gap-2 rounded bg-slate-50 px-2 py-1.5 dark:bg-slate-950"
>
<input
checked={selectedGridColumns.includes(field.path)}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={() => toggleGridColumn(field.path)}
/>
<code className="min-w-0 flex-1 truncate text-[10px] text-sky-700 dark:text-sky-300">
{field.path}
</code>
<span className="text-[9px] text-slate-400">{field.type}</span>
</label>
))}
</div>
) : (
<p className="rounded bg-slate-50 p-2 text-[10px] leading-4 text-slate-500 dark:bg-slate-950">
Sütunları oluşturmak için yukarıdaki statik veri alanına en az bir obje ekleyin.
</p>
)}
</div>
)
}
const renderDataInspector = () => {
if (selectedNode?.kind === 'platform') {
return (
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs leading-5 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
<strong className="block">Platform veri modeli</strong>
{selectedNode.type} verisini <code>listFormCode</code> üzerinden yönetir. Endpoint
bağlamanız gerekmez; liste ve kolon tanımı ilgili List Form kaydından gelir.
</div>
)
}
const selectModeSelector = renderSelectDataModeSelector()
const selectConfiguration = renderSelectDataConfiguration()
if (
(isOptionDataComponent(selectedNode?.type) || isTabularDataComponent(selectedNode?.type)) &&
selectDataMode === 'static'
) {
return (
<div className="space-y-3">
{selectModeSelector}
2026-08-06 13:17:59 +00:00
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) && renderTabularDataConfiguration()}
{isTabularDataComponent(selectedNode?.type) && renderTabularColumnConfiguration()}
2026-08-05 20:51:43 +00:00
</div>
)
}
if (!document.dataSources.length) {
return (
<div className="space-y-3">
{selectModeSelector}
<div className="rounded-lg border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-slate-700">
Bu componentte seçilebilecek tanımlı bir endpoint bulunmuyor. Endpointleri ana{' '}
<strong>Data</strong> çalışma alanından tanımlayabilirsiniz.
</div>
</div>
)
}
return (
<div className="space-y-3">
{selectModeSelector}
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
{isOptionDataComponent(selectedNode?.type) ? '1. Endpoint' : 'Endpoint'}
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2.5 py-2 text-xs dark:border-slate-700 dark:bg-slate-900"
value={activeDataSource?.id || ''}
onChange={(event) => {
const sourceId = event.target.value
setDataPanelSourceId(sourceId)
if (
(isOptionDataComponent(selectedNode?.type) ||
isTabularDataComponent(selectedNode?.type)) &&
selectDataMode === 'endpoint'
) {
if (selectedId && isTabularDataComponent(selectedNode?.type)) {
commitDocument((current) => ({
...current,
nodes: updateNodeTree(current.nodes, selectedId, (node) => {
const props = { ...node.props }
delete props.dataColumns
return {
...node,
props,
bindings: {
...node.bindings,
items: { sourceId, path: '' },
},
}
}),
}))
} else {
updateSelectedBinding(optionDataProperty, sourceId, '')
}
}
const source = document.dataSources.find((item) => item.id === sourceId)
if (source && dataSourceSamples[sourceId] === undefined) {
void testDataSource(source)
}
}}
>
{document.dataSources.map((source) => (
<option key={source.id} value={source.id}>
{source.name}
</option>
))}
</select>
</label>
</div>
{isOptionDataComponent(selectedNode?.type) && selectConfiguration}
{isTabularDataComponent(selectedNode?.type) &&
activeDataSample !== undefined &&
tabularItemsBinding?.sourceId && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<label className="block">
<span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Koleksiyon
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={tabularItemsBinding.path}
onChange={(event) =>
updateSelectedBindingDetails('items', { path: event.target.value })
}
>
{!tabularCollectionPaths.length && (
<option value="">Koleksiyon bulunamadı</option>
)}
{tabularCollectionPaths.map((path) => (
<option key={path || '__root__'} value={path}>
{path || 'Cevabın tamamı (array)'}
</option>
))}
</select>
</label>
</div>
)}
{!isOptionDataComponent(selectedNode?.type) && activeBindingSample !== undefined && (
<div className="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Kullanılabilir alanlar
</span>
<div className="flex items-center gap-2">
{isTabularDataComponent(selectedNode?.type) && gridColumnFields.length > 0 && (
<label className="flex cursor-pointer items-center gap-1 text-[9px] font-semibold text-sky-600">
<input
checked={allGridColumnsSelected}
className="h-3 w-3 accent-sky-600"
type="checkbox"
onChange={toggleAllGridColumns}
/>
{allGridColumnsSelected ? 'Tümünü kaldır' : 'Tümünü seç'}
</label>
)}
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[9px] text-emerald-700">
{isTabularDataComponent(selectedNode?.type)
? `${selectedGridColumns.length}/${gridColumnFields.length}`
: `${activeDataFields.length} alan`}
</span>
</div>
</div>
{Array.isArray(activeBindingSample) && (
<div className="mb-2 rounded bg-indigo-50 px-2 py-1.5 text-[10px] text-indigo-700 dark:bg-indigo-950 dark:text-indigo-200">
{isActiveRepeatedSource ? 'Grid satırları' : 'Koleksiyon'} ·{' '}
{activeBindingSample.length} kayıt
</div>
)}
<div className="max-h-44 space-y-1 overflow-y-auto">
{activeDataFields.length ? (
activeDataFields.map((field) => (
<label
key={field.path}
className="grid cursor-pointer grid-cols-[auto_1fr_auto] gap-2 rounded bg-slate-50 px-2 py-1.5 dark:bg-slate-950"
title={field.preview}
>
{isTabularDataComponent(selectedNode?.type) &&
!['array', 'object'].includes(field.type) ? (
<input
checked={selectedGridColumns.includes(field.path)}
className="mt-0.5 h-3 w-3 accent-sky-600"
type="checkbox"
onChange={() => toggleGridColumn(field.path)}
/>
) : (
<span />
)}
<code className="truncate text-[10px] text-sky-700 dark:text-sky-300">
{field.path}
</code>
<span className="text-[9px] text-slate-400">{field.type}</span>
<span className="col-span-2 col-start-2 truncate text-[9px] text-slate-400">
{field.preview}
</span>
</label>
))
) : (
<p className="text-[10px] text-slate-400">
Cevapta kolon bulunamadı; kök değer yine bağlanabilir.
</p>
)}
</div>
</div>
)}
{!isOptionDataComponent(selectedNode?.type) &&
!isTabularDataComponent(selectedNode?.type) && (
<div>
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-slate-400">
Property bağlantıları
</div>
{!dataBindableProperties.length ? (
<p className="rounded-lg border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-slate-700">
Bu komponent endpoint verisi alabilecek bir property tanımlamıyor.
</p>
) : (
dataBindableProperties.map((property) => {
const choice = getBindingChoice(property.name)
const collection = isCollectionProperty(property.name, property.type)
const selectableFields = collection
? activeDataFields.filter((field) => field.type === 'array')
: activeDataFields
const currentBinding = selectedNode?.bindings?.[property.name]
const currentBindingSource = document.dataSources.find(
(source) => source.id === currentBinding?.sourceId,
)
const knownChoice =
choice === '' ||
choice === '__root__' ||
selectableFields.some((field) => field.path === choice)
return (
<label
key={property.name}
className="mb-2 block rounded-lg border border-slate-200 p-2 dark:border-slate-800"
>
<span className="mb-1.5 flex items-center justify-between text-[11px] font-medium text-slate-600 dark:text-slate-300">
<span>{property.name}</span>
<span className="text-[9px] font-normal text-slate-400">
{property.tsType || property.type}
</span>
</span>
<select
className="w-full rounded-md border border-slate-300 bg-white px-2 py-2 text-[10px] dark:border-slate-700 dark:bg-slate-900"
value={choice}
onChange={(event) => {
const nextChoice = event.target.value
if (!nextChoice) {
updateSelectedBinding(property.name, '')
return
}
if (!activeDataSource) return
updateSelectedBinding(
property.name,
activeDataSource.id,
getBindingPath(nextChoice, collection),
)
}}
>
<option value="">Statik değer (bağlantı yok)</option>
{activeBindingSample !== undefined &&
(collection ||
!Array.isArray(activeBindingSample) ||
isActiveRepeatedSource) && (
<option value="__root__">
{isActiveRepeatedSource
? 'Geçerli Grid satırı'
: collection
? 'Koleksiyonun tamamı'
: 'Cevabın tamamı'}
</option>
)}
{!knownChoice && <option value={choice}>{choice} (mevcut path)</option>}
{selectableFields.map((field) => (
<option key={field.path} value={field.path}>
{field.path} · {field.type}
</option>
))}
</select>
{currentBinding?.sourceId && (
<div className="mt-1.5 truncate font-mono text-[9px] text-emerald-600">
{currentBindingSource?.name || currentBinding.sourceId}:{' '}
{currentBinding.path || '(root)'}
</div>
)}
</label>
)
})
)}
</div>
)}
</div>
)
}
const renderTree = (nodes: DesignerNode[], depth = 0): React.ReactNode =>
nodes.map((node) => (
<React.Fragment key={node.id}>
<button
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs ${selectedId === node.id ? 'bg-sky-100 font-semibold text-sky-700 dark:bg-sky-950 dark:text-sky-300' : 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'}`}
style={{ paddingLeft: 8 + depth * 14 }}
type="button"
onClick={() => selectDesignerNode(node.id)}
2026-08-05 20:51:43 +00:00
>
<FaLayerGroup className="shrink-0" />
<span className="truncate">{node.type}</span>
<span className="ml-auto text-[9px] text-slate-400">{node.id.slice(-4)}</span>
</button>
{renderTree(node.children, depth + 1)}
</React.Fragment>
))
if (!component) {
return (
<div className="flex min-h-[70vh] items-center justify-center text-sm text-slate-500">
{componentLoadError || 'Bileşen yükleniyor…'}
</div>
)
}
const canvas = (
<VisualCanvas
interactive
nodes={document.nodes}
selectedId={selectedId}
dataValues={dataSourceSamples}
renderCustomComponent={(componentName, props) => renderComponent(componentName, props)}
onSelect={(nodeId) => selectDesignerNode(nodeId || null)}
onNodePropChange={updateNodeProp}
2026-08-05 20:51:43 +00:00
onDropComponent={addComponent}
onMove={moveNode}
2026-08-06 13:17:59 +00:00
onReorder={reorderNode}
2026-08-05 20:51:43 +00:00
onDuplicate={duplicateNode}
onDelete={deleteNode}
/>
)
const javascriptEventTargets: Array<{ node: DesignerNode; eventNames: string[] }> = []
walkDesignerNodes(document.nodes, (node) => {
const definition = catalogByName.get(node.type)
const primaryEvents = DESIGNER_PRIMARY_EVENTS[node.type]
const savedEventNames = Object.entries(node.events || {})
.filter(([, script]) => script.trim())
.map(([eventName]) => eventName)
const definitionEventNames = (definition?.properties || [])
.filter((property) => property.category === 'events' || property.type === 'function')
.map((property) => property.name)
const eventNames = Array.from(
new Set([...(primaryEvents || definitionEventNames), ...savedEventNames]),
)
if (eventNames.length) javascriptEventTargets.push({ node, eventNames })
})
const activeJavaScriptTarget =
javascriptEventTargets.find((target) => target.node.id === selectedId) ||
javascriptEventTargets[0]
const endpointResultText = endpointResultModal
? typeof endpointResultModal.result === 'string'
? endpointResultModal.result
: (JSON.stringify(endpointResultModal.result, null, 2) ?? String(endpointResultModal.result))
: ''
const endpointResultCount = endpointResultModal
? Array.isArray(endpointResultModal.result)
? `${endpointResultModal.result.length} kayıt`
: endpointResultModal.result && typeof endpointResultModal.result === 'object'
? `${Object.keys(endpointResultModal.result).length} alan`
: typeof endpointResultModal.result
: ''
return (
<div className="flex h-[calc(100vh-72px)] min-h-[720px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-100 shadow-sm dark:border-slate-800 dark:bg-slate-950">
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-4 dark:border-slate-800 dark:bg-slate-900">
<div className="flex min-w-0 items-center gap-3">
<Link
className="rounded-md p-2 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
title="Bileşenlere dön"
to={ROUTES_ENUM.protected.saas.developerKit.components}
>
<FaArrowLeft />
</Link>
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-sky-500 to-indigo-600 text-white">
<FaMousePointer />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="truncate text-sm font-semibold text-slate-800 dark:text-white">
{name}
</h1>
{isDirty ? (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">
Unsaved
</span>
) : (
<FaCheckCircle className="text-emerald-500" />
)}
</div>
<p className="truncate text-[11px] text-slate-500">
Visual Component Designer · {component.routePath}
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
<Button
disabled={!undoStack.current.length}
icon={<FaUndo />}
size="xs"
title="Geri al (Ctrl+Z)"
variant="plain"
onClick={undo}
/>
<Button
disabled={!redoStack.current.length}
icon={<FaRedo />}
size="xs"
title="Yinele"
variant="plain"
onClick={redo}
/>
<div className="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700" />
<Button
icon={<FaSync />}
size="sm"
title="Canvas içeriğini sıfırla"
variant="plain"
onClick={() => {
commitDocument(() => createEmptyDesignerDocument('visual'))
setSelectedId(null)
}}
>
2026-08-06 13:17:59 +00:00
{translate('::Reset')}
2026-08-05 20:51:43 +00:00
</Button>
<Button
icon={<FaEye />}
size="sm"
title="Sayfayı yeni sekmede aç"
variant="plain"
onClick={() => {
const routePath = component.routePath.startsWith('/')
? component.routePath
: `/${component.routePath}`
window.open(routePath, '_blank')
2026-08-05 20:51:43 +00:00
}}
>
2026-08-06 13:17:59 +00:00
{translate('::OpenUrl')}
2026-08-05 20:51:43 +00:00
</Button>
<Button
loading={isSaving}
icon={<FaRegSave />}
size="sm"
variant="solid"
onClick={() => void handleSave()}
>
2026-08-06 13:17:59 +00:00
{translate('::Save')}
2026-08-05 20:51:43 +00:00
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1">
{workspaceTab === 'design' && (
<aside className="flex w-80 shrink-0 flex-col border-r border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-200 p-3 dark:border-slate-800">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-500">
<FaPlus /> Toolbox
</div>
<label className="relative block">
<FaSearch className="absolute left-2.5 top-2.5 text-xs text-slate-400" />
<input
className="w-full rounded-md border border-slate-200 bg-slate-50 py-2 pl-8 pr-2 text-xs outline-none focus:border-sky-400 dark:border-slate-700 dark:bg-slate-950"
placeholder="Bileşen ara…"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</label>
</div>
<div className="flex-1 overflow-y-auto p-2">
{groupedCatalog.map(([group, definitions]) => (
<section key={group} className="mb-5">
<button
aria-expanded={expandedToolboxGroups[group] || Boolean(search.trim())}
className="mb-2 flex w-full items-center gap-2 rounded bg-slate-100 px-2 py-1.5 text-left text-[10px] font-bold uppercase tracking-widest text-slate-600 dark:bg-slate-800 dark:text-slate-300"
type="button"
onClick={() =>
setExpandedToolboxGroups((current) => ({
...current,
[group]: !current[group],
}))
}
>
{expandedToolboxGroups[group] || search.trim() ? (
<FaChevronDown className="shrink-0" />
) : (
<FaChevronRight className="shrink-0" />
)}
<span>{TOOLBOX_GROUP_LABELS[group]}</span>
<span className="ml-auto rounded bg-white/70 px-1.5 py-0.5 text-[9px] font-medium tracking-normal dark:bg-slate-900/60">
{definitions.length}
</span>
</button>
{(expandedToolboxGroups[group] || Boolean(search.trim())) && (
<div className="grid grid-cols-3 gap-1.5">
{definitions.map((definition) => (
<button
key={`${definition.toolboxGroup}-${definition.name}`}
draggable
className="group min-w-0 rounded-lg border border-slate-200 bg-white px-2 py-2.5 text-left transition hover:border-sky-400 hover:bg-sky-50 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-sky-600 dark:hover:bg-sky-950"
title={definition.description}
type="button"
onClick={() => addComponent(definition.name, null)}
onDragStart={(event) => {
const payload = JSON.stringify({
source: 'library',
name: definition.name,
})
event.dataTransfer.effectAllowed = 'copy'
2026-08-06 13:17:59 +00:00
event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
2026-08-05 20:51:43 +00:00
event.dataTransfer.setData('text/plain', payload)
}}
>
<div className="mb-1 flex h-6 w-6 items-center justify-center rounded bg-slate-100 text-[10px] font-bold text-slate-500 group-hover:bg-sky-100 group-hover:text-sky-600 dark:bg-slate-800">
{definition.name.slice(0, 2).toUpperCase()}
</div>
<div className="truncate text-[11px] font-medium text-slate-700 dark:text-slate-200">
{definition.name}
</div>
</button>
))}
</div>
)}
</section>
))}
</div>
</aside>
)}
<main className="flex min-w-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3 dark:border-slate-800 dark:bg-slate-900">
<div className="flex h-full items-center gap-1">
{(
[
['design', 'Design', <FaMousePointer key="design" />],
['data', 'Data', <FaDatabase key="data" />],
['code', 'React', <FaCode key="code" />],
['event', 'Event', <FaBolt key="js" />],
] as [WorkspaceTab, string, React.ReactNode][]
).map(([tab, label, icon]) => (
<button
key={tab}
className={`flex h-full items-center gap-1.5 border-b-2 px-3 text-xs font-medium ${workspaceTab === tab ? 'border-sky-500 text-sky-600' : 'border-transparent text-slate-500 hover:text-slate-800 dark:hover:text-white'}`}
type="button"
onClick={() => setWorkspaceTab(tab)}
>
{icon}
{label}
</button>
))}
</div>
{workspaceTab === 'design' && (
<div className="flex items-center rounded-md border border-slate-200 p-0.5 dark:border-slate-700">
{(
[
['responsive', <FaDesktop key="r" />, 'Responsive'],
['desktop', <FaDesktop key="d" />, 'Desktop'],
['tablet', <FaTabletAlt key="t" />, 'Tablet'],
['mobile', <FaMobileAlt key="m" />, 'Mobile'],
] as [DesignerDocument['canvas']['width'], React.ReactNode, string][]
).map(([width, icon, label]) => (
<button
key={width}
className={`rounded p-1.5 ${document.canvas.width === width ? 'bg-sky-100 text-sky-600 dark:bg-sky-950' : 'text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'}`}
title={label}
type="button"
onClick={() =>
commitDocument((current) => ({
...current,
canvas: { ...current.canvas, width },
}))
}
>
{icon}
</button>
))}
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-auto bg-slate-200 p-5 dark:bg-slate-950">
{workspaceTab === 'design' ? (
<div
className="mx-auto min-h-full overflow-hidden rounded-lg bg-white text-slate-900 shadow-lg transition-[width] duration-200 dark:bg-slate-900 dark:text-slate-100"
style={{ width: CANVAS_WIDTHS[document.canvas.width], maxWidth: '100%' }}
>
{canvas}
</div>
) : workspaceTab === 'data' ? (
<div className="w-full space-y-4">
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="min-w-0">
<h2 className="flex items-center gap-2 font-semibold text-slate-800 dark:text-white">
<FaDatabase className="text-sky-600" /> Data sources
</h2>
<p className="mt-1 text-xs text-slate-500">
/api/ endpointleri mevcut kullanıcının token, tenant ve rol yetkileriyle
çağrılır.
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button icon={<FaPlus />} size="sm" variant="solid" onClick={addDataSource}>
Yeni endpoint
</Button>
</div>
</div>
<div className="rounded-lg border border-sky-200 bg-sky-50 p-3 text-xs text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200">
Endpoint ayarları component ile birlikte kaydedilir. Tekrarlı kartlar için Grid
<code className="mx-1">items</code> alanını endpointe boş path ile bağlayın; Grid
içine Card/Input bırakıp alt property pathlerinde <code>$item.name</code>{' '}
kullanın. DataGridView ise endpoint yerine kendi <code>listFormCode</code> ayarını
kullanır.
</div>
{(reusableDataSources.length > 0 || endpointCatalogError) && (
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3">
<h3 className="text-sm font-semibold text-slate-800 dark:text-white">
Kullanılabilir kaynaklar
</h3>
<p className="mt-1 text-[10px] text-slate-500">
Bu componentte kaydedilen, diğer componentlerden gelen veya CRUD Endpoint
Managerda tanımlı kaynaklar.
</p>
</div>
{renderReusableDataSources()}
</div>
)}
</div>
) : workspaceTab === 'code' ? (
<div className="h-full min-h-[560px] overflow-hidden rounded-lg border border-slate-700 bg-[#1e1e1e]">
<div className="flex items-center justify-between border-b border-slate-700 bg-slate-900 px-3 py-2 text-xs text-slate-300">
<span>
{document.sourceMode === 'visual'
? 'Generated React · read-only'
: 'Advanced React source'}
</span>
{document.sourceMode === 'visual' && (
<span className="text-emerald-400">Schema synchronized</span>
)}
</div>
<Editor
height="calc(100% - 36px)"
language="typescript"
options={{
automaticLayout: true,
fontSize: 13,
minimap: { enabled: false },
readOnly: document.sourceMode === 'visual',
scrollBeyondLastLine: false,
}}
theme="vs-dark"
value={document.sourceMode === 'visual' ? generatedCode : manualCode}
onChange={(value) => document.sourceMode === 'code' && setManualCode(value || '')}
/>
</div>
) : (
<div className="flex min-h-[620px] w-full overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="w-72 shrink-0 border-r border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950">
<div className="mb-3">
<h2 className="flex items-center gap-2 font-semibold text-slate-800 dark:text-white">
<FaBolt className="text-amber-500" /> Event workspace
</h2>
<p className="mt-1 text-[10px] leading-4 text-slate-500">
Sayfadaki güncel component eventlerini tek yerden yönetin.
</p>
</div>
<div className="space-y-1">
{javascriptEventTargets.length ? (
javascriptEventTargets.map((target) => (
<button
key={target.node.id}
className={`flex w-full items-center gap-2 rounded-md border px-2.5 py-2 text-left transition ${activeJavaScriptTarget?.node.id === target.node.id ? 'border-amber-400 bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-200' : 'border-transparent text-slate-600 hover:border-slate-200 hover:bg-white dark:text-slate-300 dark:hover:border-slate-700 dark:hover:bg-slate-900'}`}
type="button"
onClick={() => setSelectedId(target.node.id)}
>
<FaBolt className="shrink-0 text-[10px]" />
<span className="min-w-0 flex-1 truncate text-xs font-semibold">
{target.node.type}
</span>
<span className="rounded bg-slate-200 px-1.5 py-0.5 text-[9px] dark:bg-slate-800">
{target.eventNames.length}
</span>
</button>
))
) : (
<p className="rounded-md border border-dashed border-slate-300 p-3 text-xs leading-5 text-slate-500 dark:border-slate-700">
Event destekleyen bir component eklediğinizde burada görünecek.
</p>
)}
</div>
</div>
<div className="min-w-0 flex-1 overflow-y-auto p-5">
<div className="mb-5 rounded-lg border border-slate-200 p-4 dark:border-slate-800">
<div className="mb-2 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-800 dark:text-white">
Component lifecycle
</h3>
<p className="mt-0.5 text-[10px] text-slate-500">
Sayfa ilk yüklendiğinde bir kez çalışır.
</p>
</div>
<code className="text-[10px] text-sky-600">onMount()</code>
</div>
<Editor
height="150px"
language="javascript"
options={{
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
theme="vs-dark"
value={document.lifecycle.onMount}
onChange={(value) =>
commitDocument((current) => ({
...current,
lifecycle: { onMount: value || '' },
}))
}
/>
</div>
{activeJavaScriptTarget ? (
<div>
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-800 dark:text-white">
{activeJavaScriptTarget.node.type}
</h3>
<code className="text-[10px] text-slate-400">
{activeJavaScriptTarget.node.id}
</code>
</div>
<span className="rounded bg-amber-100 px-2 py-1 text-[10px] font-semibold text-amber-700 dark:bg-amber-950 dark:text-amber-200">
{activeJavaScriptTarget.eventNames.length} event
</span>
</div>
<div className="grid gap-4 xl:grid-cols-2">
{activeJavaScriptTarget.eventNames.map((eventName) => (
<label
key={eventName}
className="overflow-hidden rounded-lg border border-slate-200 dark:border-slate-800"
>
<span className="flex items-center justify-between bg-slate-50 px-3 py-2 text-xs font-semibold text-slate-700 dark:bg-slate-950 dark:text-slate-200">
<code>{eventName}(event)</code>
<span className="flex items-center gap-2">
<span className="text-[9px] font-normal text-slate-400">
{activeJavaScriptTarget.node.type}
</span>
<button
className="rounded bg-sky-100 px-2 py-1 text-[9px] font-semibold text-sky-700 hover:bg-sky-200 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-sky-950 dark:text-sky-300"
disabled={Boolean(
activeJavaScriptTarget.node.events[eventName]?.trim(),
)}
type="button"
onClick={() =>
updateNodeEvent(
activeJavaScriptTarget.node.id,
eventName,
getDesignerEventSnippet(
activeJavaScriptTarget.node.type,
eventName,
),
)
}
>
Snippet ekle
</button>
</span>
</span>
<Editor
height="190px"
language="javascript"
options={{
automaticLayout: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
theme="vs-dark"
value={activeJavaScriptTarget.node.events[eventName] || ''}
onChange={(value) =>
updateNodeEvent(
activeJavaScriptTarget.node.id,
eventName,
value || '',
)
}
/>
</label>
))}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-700">
Düzenlenecek component eventi bulunmuyor.
</div>
)}
</div>
</div>
)}
</div>
</main>
{workspaceTab === 'design' && (
2026-08-06 13:17:59 +00:00
<aside
data-property-panel
className="flex w-72 shrink-0 flex-col border-l border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
2026-08-05 20:51:43 +00:00
<div className="flex h-12 shrink-0 border-b border-slate-200 dark:border-slate-800">
{(
[
['properties', <FaSlidersH key="p" />, 'Properties'],
['data', <FaDatabase key="d" />, 'Data'],
['tree', <FaLayerGroup key="t" />, 'Tree'],
] as [InspectorTab, React.ReactNode, string][]
).map(([tab, icon, label]) => (
<button
key={tab}
className={`flex flex-1 items-center justify-center gap-1 border-b-2 text-[11px] ${tab === 'data' && !selectedIsDataComponent ? 'cursor-not-allowed border-transparent text-slate-300 dark:text-slate-700' : inspectorTab === tab ? 'border-sky-500 text-sky-600' : 'border-transparent text-slate-500'}`}
disabled={tab === 'data' && !selectedIsDataComponent}
title={
tab === 'data' && !selectedIsDataComponent
? 'Data sekmesi yalnızca Data kategorisindeki komponentlerde kullanılabilir.'
: undefined
}
2026-08-05 20:51:43 +00:00
type="button"
onClick={() => {
if (tab !== 'data' || selectedIsDataComponent) setInspectorTab(tab)
}}
2026-08-05 20:51:43 +00:00
>
{icon}
{label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-3">
{inspectorTab === 'tree' ? (
<div>
{document.nodes.length ? (
renderTree(document.nodes)
) : (
<p className="p-3 text-xs text-slate-400">Henüz bileşen yok.</p>
)}
</div>
) : selectedNode ? (
<>
<div className="mb-4 rounded-lg bg-slate-50 p-3 dark:bg-slate-950">
<div className="text-[10px] uppercase tracking-wider text-slate-400">
Selected component
</div>
<div className="mt-1 flex items-center justify-between">
<strong className="text-sm text-slate-800 dark:text-white">
{selectedNode.type}
</strong>
<button
className="rounded p-1.5 text-red-500 hover:bg-red-50"
title="Bileşeni sil"
type="button"
onClick={() => deleteNode(selectedNode.id)}
>
<FaTrash />
</button>
</div>
<code className="text-[10px] text-slate-400">{selectedNode.id}</code>
{selectedDefinition?.interfaceName && (
<div className="mt-2 border-t border-slate-200 pt-2 dark:border-slate-800">
<div className="text-[10px] font-semibold text-sky-600">
{selectedDefinition.interfaceName}
</div>
<div
className="truncate text-[9px] text-slate-400"
title={selectedDefinition.sourceFile}
>
{selectedDefinition.sourceFile}
</div>
</div>
)}
</div>
{inspectorTab === 'properties' ? (
<>
{selectedProperties
2026-08-05 20:51:43 +00:00
.filter(
(property) =>
property.category !== 'events' &&
property.type !== 'function' &&
!(
isOptionDataComponent(selectedNode.type) &&
property.name === getOptionDataProperty(selectedNode.type)
) &&
!(
isTabularDataComponent(selectedNode.type) &&
['items', 'data', 'columns', 'dataColumns'].includes(property.name)
),
)
.map((property) => {
2026-08-06 13:17:59 +00:00
const propertyValue = selectedNode.props[property.name] ?? property.value
const helperValue = getPropertyExampleValue(property, property.value)
const snippet = getPropertySnippet(property, helperValue)
2026-08-05 20:51:43 +00:00
return (
<div
key={property.name}
className="group/property mb-4 rounded-md border border-slate-200 p-2 dark:border-slate-800"
2026-08-05 20:51:43 +00:00
>
<span className="mb-1.5 flex items-center justify-between text-[11px] font-medium text-slate-600 dark:text-slate-300">
<span>
{property.name}
{property.required ? (
<b className="ml-0.5 text-red-500">*</b>
) : null}
</span>
<span className="flex items-center gap-1 text-[9px] font-normal text-slate-400">
<span>{property.tsType || property.type}</span>
<span className="group/snippet relative">
<button
className="rounded p-1 text-sky-500 opacity-0 transition hover:bg-sky-50 hover:text-sky-700 focus:opacity-100 group-hover/property:opacity-100 dark:hover:bg-sky-950"
title="Yardımcı değeri uygula"
type="button"
onClick={() =>
updateSelectedProp(property.name, clone(helperValue))
}
>
<FaBolt />
</button>
<span className="pointer-events-none absolute right-0 top-full z-30 mt-1 hidden w-56 rounded-md border border-slate-200 bg-white p-2 text-left font-mono text-[9px] leading-4 text-sky-700 shadow-lg group-hover/snippet:block dark:border-slate-700 dark:bg-slate-950 dark:text-sky-300">
{snippet}
<span className="mt-1 block font-sans text-[9px] text-slate-400">
Tıklayınca property değerine uygulanır.
</span>
</span>
</span>
2026-08-05 20:51:43 +00:00
</span>
</span>
{property.description && (
<p className="mb-2 text-[10px] leading-4 text-slate-400">
{property.description}
</p>
)}
<PropertyEditor
name={property.name}
type={property.type}
value={propertyValue}
options={property.options}
onChange={(value) => updateSelectedProp(property.name, value)}
/>
</div>
)
})}
</>
) : (
renderDataInspector()
)}
2026-08-05 20:51:43 +00:00
</>
) : (
<div className="space-y-4">
<div className="rounded-lg border border-dashed border-slate-300 p-5 text-center dark:border-slate-700">
<FaMousePointer className="mx-auto mb-2 text-slate-400" />
<p className="text-xs text-slate-500">
Propertyleri görmek için canvastan bir bileşen seçin.
</p>
</div>
</div>
)}
</div>
<div className="border-t border-slate-200 bg-slate-50 px-3 py-2 text-[10px] text-slate-500 dark:border-slate-800 dark:bg-slate-950">
<span className="font-semibold">{document.nodes.length}</span> root component ·{' '}
<span className="font-semibold">Ctrl+S</span> save ·{' '}
<span className="font-semibold">Del</span> delete
</div>
</aside>
)}
</div>
{catalogSourceEditor && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/70 p-4">
<div className="w-full max-w-2xl overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900">
<div className="flex items-start justify-between border-b border-slate-200 px-5 py-4 dark:border-slate-700">
<div>
<h2 className="text-sm font-semibold text-slate-800 dark:text-white">
{catalogSourceEditor.item ? 'Endpointi düzenle' : 'Yeni endpoint'}
</h2>
<p className="mt-1 text-[10px] text-slate-500">
{catalogSourceEditor.item?.origin ||
'Bu componente yeni bir GET kaynağı ekleyin.'}
</p>
</div>
<button
className="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-white"
title="Kapat"
type="button"
onClick={() => {
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
}}
>
×
</button>
</div>
<div className="space-y-4 p-5">
<div className="grid gap-4 md:grid-cols-[1fr_110px]">
<label>
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Ad
</span>
<input
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-xs text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
value={catalogSourceEditor.draft.name}
onChange={(event) => updateCatalogSourceDraft({ name: event.target.value })}
/>
</label>
<label>
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Metot
</span>
<input
readOnly
className="w-full rounded-md border border-slate-300 bg-slate-100 px-3 py-2 text-xs dark:border-slate-700 dark:bg-slate-800"
value={catalogSourceEditor.draft.method}
/>
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Endpoint URL
</span>
<input
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
placeholder="/api/app/customers"
value={catalogSourceEditor.draft.url}
onChange={(event) => updateCatalogSourceDraft({ url: event.target.value })}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-[10px] font-semibold uppercase text-slate-500">
Cevap kök yolu <span className="font-normal normal-case">(opsiyonel)</span>
</span>
<input
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100"
placeholder="result.items"
value={catalogSourceEditor.draft.responsePath}
onChange={(event) =>
updateCatalogSourceDraft({ responsePath: event.target.value })
}
/>
</label>
{catalogSourceTestResult && catalogSourceTestResult.status !== 'success' && (
<div
className={`max-h-56 overflow-auto whitespace-pre-wrap break-all rounded-md p-3 font-mono text-[11px] ${
catalogSourceTestResult.status === 'error'
? 'bg-red-950 text-red-200'
: catalogSourceTestResult.status === 'loading'
? 'bg-slate-950 text-slate-300'
: 'bg-slate-950 text-emerald-300'
}`}
>
{catalogSourceTestResult.message}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-slate-200 bg-slate-50 px-5 py-4 dark:border-slate-700 dark:bg-slate-950">
<Button
size="sm"
variant="plain"
onClick={() => {
setCatalogSourceEditor(null)
setCatalogSourceTestResult(null)
}}
>
Vazgeç
</Button>
<Button
disabled={!isRunnableDataSourceUrl(catalogSourceEditor.draft.url)}
icon={<FaPlay />}
loading={catalogSourceTestResult?.status === 'loading'}
size="sm"
variant="default"
onClick={() => void testCatalogSource()}
>
Çalıştır
</Button>
<Button
disabled={
!catalogSourceEditor.draft.name.trim() ||
!isRunnableDataSourceUrl(catalogSourceEditor.draft.url) ||
catalogSourceTestResult?.status !== 'success'
}
icon={<FaRegSave />}
loading={isCatalogSourceSaving}
size="sm"
variant="solid"
onClick={() => void saveCatalogSource()}
>
{catalogSourceEditor.item ? 'Değişiklikleri kaydet' : 'Endpointi ekle'}
</Button>
</div>
</div>
</div>
)}
{endpointResultModal && (
<div
aria-label="Endpoint sonucu"
aria-modal="true"
className="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/75 p-4"
role="dialog"
onMouseDown={(event) => {
if (event.currentTarget === event.target) setEndpointResultModal(null)
}}
>
<div className="flex max-h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-slate-700 bg-slate-900 shadow-2xl">
<div className="flex items-start justify-between border-b border-slate-700 px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<FaDatabase className="text-sky-400" />
<h2 className="truncate text-sm font-semibold text-white">
{endpointResultModal.source.name}
</h2>
<span className="rounded bg-emerald-950 px-2 py-0.5 text-[10px] font-semibold text-emerald-300">
Başarılı
</span>
</div>
<div className="mt-1 break-all font-mono text-[10px] text-slate-400">
{endpointResultModal.source.method} {endpointResultModal.source.url}
</div>
</div>
<button
aria-label="Sonuç modalını kapat"
className="ml-4 rounded-md px-2 py-1 text-xl text-slate-400 hover:bg-slate-800 hover:text-white"
type="button"
onClick={() => setEndpointResultModal(null)}
>
×
</button>
</div>
<div className="flex items-center gap-3 border-b border-slate-800 bg-slate-950/60 px-5 py-2 text-[10px] text-slate-400">
<span>
Response path:{' '}
<code className="text-sky-300">
{endpointResultModal.source.responsePath || '(root)'}
</code>
</span>
<span className="rounded bg-slate-800 px-2 py-0.5 text-slate-300">
{endpointResultCount}
</span>
</div>
<pre className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-5 font-mono text-xs leading-5 text-emerald-300">
{endpointResultText}
</pre>
<div className="flex justify-end border-t border-slate-700 bg-slate-950 px-5 py-3">
<Button size="sm" variant="default" onClick={() => setEndpointResultModal(null)}>
Kapat
</Button>
</div>
</div>
</div>
)}
</div>
)
}
export default VisualComponentDesigner