feat: expand live chat and work server tools
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
310
src/views/play/LayoutPreviewWidgets.tsx
Normal file
310
src/views/play/LayoutPreviewWidgets.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
LeftOutlined,
|
||||
PlusOutlined,
|
||||
RightOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Empty, Input, Tag, Typography } from 'antd';
|
||||
import type { SyntheticEvent } from 'react';
|
||||
import { InputUI } from '../../components/inputs/primitives/input';
|
||||
import { SelectUI, type SelectOptionItem } from '../../components/inputs/select';
|
||||
import type {
|
||||
LayoutPreviewBaseInputState,
|
||||
LayoutPreviewEmptyPaneState,
|
||||
LayoutPreviewMemoState,
|
||||
LayoutPreviewSelectState,
|
||||
} from './layoutPreviewRuntime';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function stopPreviewEvent(event: SyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function formatMemoTimestamp(value: string) {
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function getMemoPreview(body: string) {
|
||||
return body.replace(/\s+/g, ' ').trim() || '새 메모';
|
||||
}
|
||||
|
||||
function formatEmptyPaneTimestamp(value: string | null) {
|
||||
if (!value) {
|
||||
return '아직 메모가 없습니다.';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
const EMPTY_PANE_READINESS_META: Record<
|
||||
LayoutPreviewEmptyPaneState['readiness'],
|
||||
{ label: string; tone: 'default' | 'processing' | 'success' }
|
||||
> = {
|
||||
unassigned: { label: '컴포넌트 대기', tone: 'default' },
|
||||
drafting: { label: '요구 정리 중', tone: 'processing' },
|
||||
ready: { label: '준비 완료', tone: 'success' },
|
||||
};
|
||||
|
||||
export function LayoutPreviewTextMemoPane({
|
||||
state,
|
||||
onStartDraft,
|
||||
onToggleList,
|
||||
onDeleteSelection,
|
||||
onSaveDraft,
|
||||
onSelectNote,
|
||||
onMoveSelection,
|
||||
onDraftChange,
|
||||
}: {
|
||||
state: LayoutPreviewMemoState;
|
||||
onStartDraft: () => void;
|
||||
onToggleList: () => void;
|
||||
onDeleteSelection: () => void;
|
||||
onSaveDraft: () => void;
|
||||
onSelectNote: (noteId: string) => void;
|
||||
onMoveSelection: (direction: -1 | 1) => void;
|
||||
onDraftChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const selectedIndex = state.selectedId ? state.notes.findIndex((note) => note.id === state.selectedId) : -1;
|
||||
const selectedNote = selectedIndex >= 0 ? state.notes[selectedIndex] : null;
|
||||
const hasDraft = state.draftBody.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="layout-playground__memo-widget-preview" onClick={stopPreviewEvent}>
|
||||
<div className="layout-playground__memo-widget-preview-toolbar" role="toolbar" aria-label="메모 도구">
|
||||
<div className="layout-playground__memo-widget-preview-toolbar-group">
|
||||
<Button type="text" shape="circle" htmlType="button" icon={<PlusOutlined />} aria-label="새 메모" onClick={onStartDraft} />
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
htmlType="button"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="메모 삭제"
|
||||
disabled={!selectedNote && !hasDraft}
|
||||
onClick={onDeleteSelection}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
htmlType="button"
|
||||
icon={<UnorderedListOutlined />}
|
||||
aria-label="메모 목록"
|
||||
onClick={onToggleList}
|
||||
/>
|
||||
</div>
|
||||
<div className="layout-playground__memo-widget-preview-toolbar-group">
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
htmlType="button"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={selectedIndex <= 0}
|
||||
onClick={() => {
|
||||
onMoveSelection(-1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
htmlType="button"
|
||||
icon={<RightOutlined />}
|
||||
disabled={selectedIndex < 0 || selectedIndex >= state.notes.length - 1}
|
||||
onClick={() => {
|
||||
onMoveSelection(1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
htmlType="button"
|
||||
icon={<CheckOutlined />}
|
||||
aria-label="저장"
|
||||
disabled={!hasDraft && !selectedNote}
|
||||
onClick={onSaveDraft}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`layout-playground__memo-widget-preview-body${state.isListOpen ? ' layout-playground__memo-widget-preview-body--list' : ''}`}>
|
||||
{state.isLoading ? (
|
||||
<div className="layout-playground__memo-widget-preview-empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="메모를 준비하는 중입니다." />
|
||||
</div>
|
||||
) : state.isListOpen ? (
|
||||
<div className="layout-playground__memo-widget-preview-sheet">
|
||||
{state.notes.length ? (
|
||||
<div className="layout-playground__memo-widget-preview-list">
|
||||
{state.notes.map((note) => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
className={`layout-playground__memo-widget-preview-item${note.id === state.selectedId ? ' layout-playground__memo-widget-preview-item--active' : ''}`}
|
||||
onClick={() => {
|
||||
onSelectNote(note.id);
|
||||
}}
|
||||
>
|
||||
<span className="layout-playground__memo-widget-preview-item-time">
|
||||
{formatMemoTimestamp(note.updatedAt)}
|
||||
</span>
|
||||
<span className="layout-playground__memo-widget-preview-item-copy">{getMemoPreview(note.body)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="layout-playground__memo-widget-preview-empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 메모가 없습니다." />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="layout-playground__memo-widget-preview-editor">
|
||||
<div className="layout-playground__memo-widget-preview-meta">
|
||||
<span>{selectedNote ? formatMemoTimestamp(selectedNote.updatedAt) : '새 메모'}</span>
|
||||
<span>{state.draftBody.length}/1200</span>
|
||||
</div>
|
||||
<Input.TextArea
|
||||
className="layout-playground__memo-widget-preview-input"
|
||||
value={state.draftBody}
|
||||
placeholder="본문 메모를 입력하세요."
|
||||
variant="borderless"
|
||||
maxLength={1200}
|
||||
autoSize={false}
|
||||
onChange={(event) => {
|
||||
onDraftChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutPreviewBaseInputPane({
|
||||
state,
|
||||
fillPane = false,
|
||||
placeholder = '입력 후 Enter 또는 blur',
|
||||
}: {
|
||||
state: LayoutPreviewBaseInputState;
|
||||
fillPane?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`layout-playground__base-input-preview${fillPane ? ' layout-playground__base-input-preview--fill' : ''}`}
|
||||
onClick={stopPreviewEvent}
|
||||
>
|
||||
<InputUI
|
||||
className="layout-playground__base-input-preview-field"
|
||||
value={state.value}
|
||||
placeholder={placeholder}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutPreviewSelectPane({
|
||||
state,
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
state: LayoutPreviewSelectState;
|
||||
data: SelectOptionItem[];
|
||||
onChange: (nextCode?: string, item?: SelectOptionItem) => void;
|
||||
}) {
|
||||
const resolvedSelectedCode =
|
||||
state.selectedCode && data.some((item) => item.code === state.selectedCode)
|
||||
? state.selectedCode
|
||||
: data[0]?.code;
|
||||
const formatComboLabel = (item: SelectOptionItem) => item.value;
|
||||
|
||||
return (
|
||||
<div className="layout-playground__select-preview" onClick={stopPreviewEvent}>
|
||||
<SelectUI
|
||||
data={data}
|
||||
value={resolvedSelectedCode}
|
||||
allowClear
|
||||
placeholder="콤보 값을 선택하세요"
|
||||
formatLabel={formatComboLabel}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutPreviewEmptyPane({
|
||||
paneLabel,
|
||||
paneDescription,
|
||||
sizeSummary,
|
||||
state,
|
||||
onReadinessChange,
|
||||
onNoteChange,
|
||||
}: {
|
||||
paneLabel: string;
|
||||
paneDescription: string;
|
||||
sizeSummary: string;
|
||||
state: LayoutPreviewEmptyPaneState;
|
||||
onReadinessChange: (nextValue: LayoutPreviewEmptyPaneState['readiness']) => void;
|
||||
onNoteChange: (nextValue: string) => void;
|
||||
}) {
|
||||
const readinessMeta = EMPTY_PANE_READINESS_META[state.readiness];
|
||||
|
||||
return (
|
||||
<div className="layout-playground__empty-pane-preview" onClick={stopPreviewEvent}>
|
||||
<div className="layout-playground__empty-pane-preview-head">
|
||||
<div className="layout-playground__empty-pane-preview-copy">
|
||||
<Text strong>{paneLabel}</Text>
|
||||
<Text type="secondary">{paneDescription}</Text>
|
||||
</div>
|
||||
<Tag color={readinessMeta.tone}>{readinessMeta.label}</Tag>
|
||||
</div>
|
||||
|
||||
<div className="layout-playground__empty-pane-preview-meta">
|
||||
<div className="layout-playground__empty-pane-preview-meta-item">
|
||||
<span>배치 규칙</span>
|
||||
<strong>{sizeSummary}</strong>
|
||||
</div>
|
||||
<div className="layout-playground__empty-pane-preview-meta-item">
|
||||
<span>최근 변경</span>
|
||||
<strong>{formatEmptyPaneTimestamp(state.updatedAt)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="layout-playground__empty-pane-preview-actions">
|
||||
<Button type={state.readiness === 'unassigned' ? 'primary' : 'default'} size="small" onClick={() => onReadinessChange('unassigned')}>
|
||||
대기
|
||||
</Button>
|
||||
<Button type={state.readiness === 'drafting' ? 'primary' : 'default'} size="small" onClick={() => onReadinessChange('drafting')}>
|
||||
정리 중
|
||||
</Button>
|
||||
<Button type={state.readiness === 'ready' ? 'primary' : 'default'} size="small" onClick={() => onReadinessChange('ready')}>
|
||||
준비 완료
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input.TextArea
|
||||
className="layout-playground__empty-pane-preview-note"
|
||||
value={state.note}
|
||||
placeholder="이 section에 들어갈 역할이나 메모를 간단히 정리하세요."
|
||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||
maxLength={400}
|
||||
onChange={(event) => {
|
||||
onNoteChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/views/play/LayoutSavedPanePlaceholder.tsx
Normal file
85
src/views/play/LayoutSavedPanePlaceholder.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Button, Tag, Typography } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
export type LayoutSavedPaneSpec = {
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export function LayoutSavedPanePlaceholder({
|
||||
label,
|
||||
selected,
|
||||
spec,
|
||||
action,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
spec: LayoutSavedPaneSpec | null;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`layout-playground__saved-pane-placeholder${selected ? ' is-selected' : ''}`}>
|
||||
<div className="layout-playground__saved-pane-placeholder-head">
|
||||
<Tag color={selected ? 'blue' : 'default'}>{label}</Tag>
|
||||
<Tag bordered={false} className="layout-playground__saved-pane-placeholder-status">
|
||||
컴포넌트 미지정
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="layout-playground__saved-pane-placeholder-body">
|
||||
<Text strong className="layout-playground__saved-pane-placeholder-title">
|
||||
등록된 기능 명세가 없습니다.
|
||||
</Text>
|
||||
<Paragraph className="layout-playground__saved-pane-placeholder-copy">
|
||||
현재 section은 레이아웃 구조만 저장되어 있습니다. 필요한 컴포넌트와 상호작용은 이후에 독립적으로 연결할 수 있습니다.
|
||||
</Paragraph>
|
||||
{spec ? (
|
||||
<div className="layout-playground__saved-pane-placeholder-meta">
|
||||
<div className="layout-playground__saved-pane-placeholder-meta-item layout-playground__saved-pane-placeholder-meta-item--wide">
|
||||
<span>배치 규칙</span>
|
||||
<strong>{spec.summary}</strong>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="layout-playground__saved-pane-placeholder-actions">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutSavedSelectionSummary({
|
||||
items,
|
||||
selectedId,
|
||||
onSelect,
|
||||
spec,
|
||||
}: {
|
||||
items: Array<{ id: string; label: string }>;
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
spec: LayoutSavedPaneSpec | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="layout-playground__saved-selection-summary">
|
||||
<div className="layout-playground__saved-selection-summary-copy">
|
||||
<Text strong>{items.find((item) => item.id === selectedId)?.label ?? 'Pane 선택 없음'}</Text>
|
||||
<Text type="secondary">
|
||||
{spec ? spec.summary : '분할 정보가 없습니다.'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="layout-playground__saved-selection-summary-actions">
|
||||
{items.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
size="small"
|
||||
type={item.id === selectedId ? 'primary' : 'default'}
|
||||
onClick={() => {
|
||||
onSelect(item.id);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/views/play/layoutCodexChatType.ts
Normal file
20
src/views/play/layoutCodexChatType.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { LAYOUT_EDITOR_CHAT_TYPE_ID } from '../../app/main/chatTypeDefaults';
|
||||
|
||||
type LayoutCodexChatType = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const PRIORITIZED_CHAT_TYPE_IDS = [LAYOUT_EDITOR_CHAT_TYPE_ID, 'general-request', 'api-request-template'] as const;
|
||||
|
||||
export function resolvePreferredLayoutCodexChatType(chatTypes: LayoutCodexChatType[]) {
|
||||
for (const id of PRIORITIZED_CHAT_TYPE_IDS) {
|
||||
const matched = chatTypes.find((item) => item.id === id);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
}
|
||||
|
||||
return chatTypes.find((item) => item.id !== 'general-inquiry') ?? chatTypes[0] ?? null;
|
||||
}
|
||||
484
src/views/play/layoutPreviewRuntime.ts
Normal file
484
src/views/play/layoutPreviewRuntime.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { SelectOptionItem } from '../../components/inputs/select';
|
||||
|
||||
type LayoutComponentBinding = {
|
||||
optionId: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type LayoutLeafNode = {
|
||||
id: string;
|
||||
componentBinding: LayoutComponentBinding | null;
|
||||
};
|
||||
|
||||
export type LayoutPreviewBindingKind = 'base-input' | 'select-input' | 'text-memo-widget' | 'sample';
|
||||
|
||||
export type LayoutPreviewMemoNote = {
|
||||
id: string;
|
||||
body: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type LayoutPreviewMemoState = {
|
||||
draftBody: string;
|
||||
selectedId: string | null;
|
||||
isListOpen: boolean;
|
||||
isLoading: boolean;
|
||||
notes: LayoutPreviewMemoNote[];
|
||||
};
|
||||
|
||||
export type LayoutPreviewBaseInputState = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type LayoutPreviewSelectState = {
|
||||
selectedCode: string | undefined;
|
||||
selectedItem: SelectOptionItem | null;
|
||||
};
|
||||
|
||||
export type LayoutPreviewEmptyPaneState = {
|
||||
readiness: 'unassigned' | 'drafting' | 'ready';
|
||||
note: string;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type LayoutPreviewInteractionRule = {
|
||||
sourceLeafId: string;
|
||||
targetLeafId: string;
|
||||
};
|
||||
|
||||
type LayoutPreviewRuntime = {
|
||||
memoStates: Record<string, LayoutPreviewMemoState>;
|
||||
baseInputStates: Record<string, LayoutPreviewBaseInputState>;
|
||||
selectStates: Record<string, LayoutPreviewSelectState>;
|
||||
emptyPaneStates: Record<string, LayoutPreviewEmptyPaneState>;
|
||||
setMemoDraftBody: (leafId: string, nextValue: string) => void;
|
||||
toggleMemoList: (leafId: string) => void;
|
||||
saveMemoDraft: (leafId: string) => void;
|
||||
startMemoDraft: (leafId: string) => void;
|
||||
selectMemoNote: (leafId: string, noteId: string) => void;
|
||||
moveMemoSelection: (leafId: string, direction: -1 | 1) => void;
|
||||
deleteMemoSelection: (leafId: string) => void;
|
||||
setSelectValue: (leafId: string, nextCode?: string, nextItem?: SelectOptionItem) => void;
|
||||
setEmptyPaneReadiness: (leafId: string, readiness: LayoutPreviewEmptyPaneState['readiness']) => void;
|
||||
setEmptyPaneNote: (leafId: string, nextValue: string) => void;
|
||||
};
|
||||
|
||||
const EMPTY_MEMO_STATE: LayoutPreviewMemoState = {
|
||||
draftBody: '',
|
||||
selectedId: null,
|
||||
isListOpen: false,
|
||||
isLoading: false,
|
||||
notes: [],
|
||||
};
|
||||
|
||||
const EMPTY_BASE_INPUT_STATE: LayoutPreviewBaseInputState = {
|
||||
value: '',
|
||||
};
|
||||
|
||||
const EMPTY_SELECT_STATE: LayoutPreviewSelectState = {
|
||||
selectedCode: undefined,
|
||||
selectedItem: null,
|
||||
};
|
||||
|
||||
const EMPTY_EMPTY_PANE_STATE: LayoutPreviewEmptyPaneState = {
|
||||
readiness: 'unassigned',
|
||||
note: '',
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
function createMemoNote(body: string): LayoutPreviewMemoNote {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: `memo-preview-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
body,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function extractMemoFirstLine(body: string) {
|
||||
return body
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? '';
|
||||
}
|
||||
|
||||
export function resolveLayoutPreviewBindingKind(binding: LayoutComponentBinding | null): LayoutPreviewBindingKind {
|
||||
if (!binding) {
|
||||
return 'sample';
|
||||
}
|
||||
|
||||
if (
|
||||
binding.optionId === 'component:input:deferred-input' ||
|
||||
binding.optionId === 'component:input:input-base' ||
|
||||
binding.label === 'Base Input'
|
||||
) {
|
||||
return 'base-input';
|
||||
}
|
||||
|
||||
if (
|
||||
binding.optionId === 'component:select-input:select-input-base' ||
|
||||
binding.optionId === 'component:select-input:select-input' ||
|
||||
binding.label === 'Select Input'
|
||||
) {
|
||||
return 'select-input';
|
||||
}
|
||||
|
||||
if (binding.optionId === 'widget:text-memo-widget:text-memo-widget' || binding.label === 'Text Memo Widget') {
|
||||
return 'text-memo-widget';
|
||||
}
|
||||
|
||||
return 'sample';
|
||||
}
|
||||
|
||||
function syncRecordKeys<T>(
|
||||
previous: Record<string, T>,
|
||||
nextKeys: string[],
|
||||
createValue: () => T,
|
||||
) {
|
||||
const nextSet = new Set(nextKeys);
|
||||
let changed = false;
|
||||
const nextRecord: Record<string, T> = {};
|
||||
|
||||
nextKeys.forEach((key) => {
|
||||
if (key in previous) {
|
||||
nextRecord[key] = previous[key];
|
||||
return;
|
||||
}
|
||||
|
||||
nextRecord[key] = createValue();
|
||||
changed = true;
|
||||
});
|
||||
|
||||
Object.keys(previous).forEach((key) => {
|
||||
if (!nextSet.has(key)) {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? nextRecord : previous;
|
||||
}
|
||||
|
||||
export function useLayoutPreviewRuntime(
|
||||
leafNodes: LayoutLeafNode[],
|
||||
interactionRules: LayoutPreviewInteractionRule[],
|
||||
): LayoutPreviewRuntime {
|
||||
const leafBindingKindMap = useMemo(
|
||||
() => new Map(leafNodes.map((leaf) => [leaf.id, resolveLayoutPreviewBindingKind(leaf.componentBinding)])),
|
||||
[leafNodes],
|
||||
);
|
||||
const baseInputLeafIds = useMemo(
|
||||
() =>
|
||||
leafNodes
|
||||
.filter((leaf) => resolveLayoutPreviewBindingKind(leaf.componentBinding) === 'base-input')
|
||||
.map((leaf) => leaf.id),
|
||||
[leafNodes],
|
||||
);
|
||||
const memoLeafIds = useMemo(
|
||||
() =>
|
||||
leafNodes
|
||||
.filter((leaf) => resolveLayoutPreviewBindingKind(leaf.componentBinding) === 'text-memo-widget')
|
||||
.map((leaf) => leaf.id),
|
||||
[leafNodes],
|
||||
);
|
||||
const selectLeafIds = useMemo(
|
||||
() =>
|
||||
leafNodes
|
||||
.filter((leaf) => resolveLayoutPreviewBindingKind(leaf.componentBinding) === 'select-input')
|
||||
.map((leaf) => leaf.id),
|
||||
[leafNodes],
|
||||
);
|
||||
const [memoStates, setMemoStates] = useState<Record<string, LayoutPreviewMemoState>>({});
|
||||
const [baseInputStates, setBaseInputStates] = useState<Record<string, LayoutPreviewBaseInputState>>({});
|
||||
const [selectStates, setSelectStates] = useState<Record<string, LayoutPreviewSelectState>>({});
|
||||
const [emptyPaneStates, setEmptyPaneStates] = useState<Record<string, LayoutPreviewEmptyPaneState>>({});
|
||||
|
||||
const memoToBaseInputTargets = useMemo(() => {
|
||||
const nextMap = new Map<string, string[]>();
|
||||
|
||||
interactionRules.forEach((rule) => {
|
||||
const sourceKind = leafBindingKindMap.get(rule.sourceLeafId);
|
||||
const targetKind = leafBindingKindMap.get(rule.targetLeafId);
|
||||
|
||||
if (sourceKind !== 'text-memo-widget' || targetKind !== 'base-input') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTargets = nextMap.get(rule.sourceLeafId) ?? [];
|
||||
|
||||
if (!nextTargets.includes(rule.targetLeafId)) {
|
||||
nextTargets.push(rule.targetLeafId);
|
||||
}
|
||||
|
||||
nextMap.set(rule.sourceLeafId, nextTargets);
|
||||
});
|
||||
|
||||
return nextMap;
|
||||
}, [interactionRules, leafBindingKindMap]);
|
||||
|
||||
const syncBaseInputsFromMemo = (sourceLeafId: string, draftBody: string) => {
|
||||
const targetLeafIds = memoToBaseInputTargets.get(sourceLeafId) ?? [];
|
||||
|
||||
if (!targetLeafIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextValue = extractMemoFirstLine(draftBody);
|
||||
|
||||
setBaseInputStates((previous) => {
|
||||
const nextState = { ...previous };
|
||||
|
||||
targetLeafIds.forEach((leafId) => {
|
||||
nextState[leafId] = {
|
||||
...(previous[leafId] ?? EMPTY_BASE_INPUT_STATE),
|
||||
value: nextValue,
|
||||
};
|
||||
});
|
||||
|
||||
return nextState;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setBaseInputStates((previous) => syncRecordKeys(previous, baseInputLeafIds, () => ({ ...EMPTY_BASE_INPUT_STATE })));
|
||||
}, [baseInputLeafIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setMemoStates((previous) => syncRecordKeys(previous, memoLeafIds, () => ({ ...EMPTY_MEMO_STATE })));
|
||||
}, [memoLeafIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectStates((previous) => syncRecordKeys(previous, selectLeafIds, () => ({ ...EMPTY_SELECT_STATE })));
|
||||
}, [selectLeafIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const emptyPaneLeafIds = leafNodes.filter((leaf) => !leaf.componentBinding).map((leaf) => leaf.id);
|
||||
setEmptyPaneStates((previous) => syncRecordKeys(previous, emptyPaneLeafIds, () => ({ ...EMPTY_EMPTY_PANE_STATE })));
|
||||
}, [leafNodes]);
|
||||
|
||||
const setMemoDraftBody = (leafId: string, nextValue: string) => {
|
||||
syncBaseInputsFromMemo(leafId, nextValue);
|
||||
setMemoStates((previous) => ({
|
||||
...previous,
|
||||
[leafId]: {
|
||||
...(previous[leafId] ?? EMPTY_MEMO_STATE),
|
||||
draftBody: nextValue,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleMemoList = (leafId: string) => {
|
||||
setMemoStates((previous) => ({
|
||||
...previous,
|
||||
[leafId]: {
|
||||
...(previous[leafId] ?? EMPTY_MEMO_STATE),
|
||||
isListOpen: !(previous[leafId]?.isListOpen ?? false),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const saveMemoDraft = (leafId: string) => {
|
||||
const current = memoStates[leafId] ?? EMPTY_MEMO_STATE;
|
||||
const trimmedBody = current.draftBody.trim();
|
||||
|
||||
if (!trimmedBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncBaseInputsFromMemo(leafId, trimmedBody);
|
||||
setMemoStates((previous) => {
|
||||
const nextCurrent = previous[leafId] ?? EMPTY_MEMO_STATE;
|
||||
const matchedNote = nextCurrent.selectedId ? nextCurrent.notes.find((note) => note.id === nextCurrent.selectedId) ?? null : null;
|
||||
const nextNote = matchedNote
|
||||
? {
|
||||
...matchedNote,
|
||||
body: trimmedBody,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: createMemoNote(trimmedBody);
|
||||
const filteredNotes = nextCurrent.notes.filter((note) => note.id !== nextNote.id);
|
||||
|
||||
return {
|
||||
...previous,
|
||||
[leafId]: {
|
||||
...nextCurrent,
|
||||
draftBody: nextNote.body,
|
||||
selectedId: nextNote.id,
|
||||
isListOpen: false,
|
||||
notes: [nextNote, ...filteredNotes].slice(0, 12),
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const startMemoDraft = (leafId: string) => {
|
||||
syncBaseInputsFromMemo(leafId, '');
|
||||
setMemoStates((previous) => ({
|
||||
...previous,
|
||||
[leafId]: {
|
||||
...(previous[leafId] ?? EMPTY_MEMO_STATE),
|
||||
draftBody: '',
|
||||
selectedId: null,
|
||||
isListOpen: false,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const selectMemoNote = (leafId: string, noteId: string) => {
|
||||
const current = memoStates[leafId] ?? EMPTY_MEMO_STATE;
|
||||
const selectedNote = current.notes.find((note) => note.id === noteId) ?? null;
|
||||
|
||||
if (!selectedNote) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncBaseInputsFromMemo(leafId, selectedNote.body);
|
||||
setMemoStates((previous) => {
|
||||
const nextCurrent = previous[leafId] ?? EMPTY_MEMO_STATE;
|
||||
const nextSelectedNote = nextCurrent.notes.find((note) => note.id === noteId) ?? null;
|
||||
|
||||
if (!nextSelectedNote) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
...previous,
|
||||
[leafId]: {
|
||||
...nextCurrent,
|
||||
selectedId: nextSelectedNote.id,
|
||||
draftBody: nextSelectedNote.body,
|
||||
isListOpen: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const deleteMemoSelection = (leafId: string) => {
|
||||
const current = memoStates[leafId] ?? EMPTY_MEMO_STATE;
|
||||
|
||||
if (!current.selectedId && !current.draftBody.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!current.selectedId) {
|
||||
syncBaseInputsFromMemo(leafId, '');
|
||||
} else {
|
||||
const nextNotes = current.notes.filter((note) => note.id !== current.selectedId);
|
||||
const nextSelectedNote = nextNotes[0] ?? null;
|
||||
syncBaseInputsFromMemo(leafId, nextSelectedNote?.body ?? '');
|
||||
}
|
||||
|
||||
setMemoStates((previous) => {
|
||||
const nextCurrent = previous[leafId] ?? EMPTY_MEMO_STATE;
|
||||
|
||||
if (!nextCurrent.selectedId && !nextCurrent.draftBody.trim()) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
if (!nextCurrent.selectedId) {
|
||||
return {
|
||||
...previous,
|
||||
[leafId]: {
|
||||
...nextCurrent,
|
||||
draftBody: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextNotes = nextCurrent.notes.filter((note) => note.id !== nextCurrent.selectedId);
|
||||
const nextSelectedNote = nextNotes[0] ?? null;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
[leafId]: {
|
||||
...nextCurrent,
|
||||
notes: nextNotes,
|
||||
selectedId: nextSelectedNote?.id ?? null,
|
||||
draftBody: nextSelectedNote?.body ?? '',
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const moveMemoSelection = (leafId: string, direction: -1 | 1) => {
|
||||
setMemoStates((previous) => {
|
||||
const current = previous[leafId] ?? EMPTY_MEMO_STATE;
|
||||
|
||||
if (!current.notes.length) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const baseIndex = current.selectedId
|
||||
? current.notes.findIndex((note) => note.id === current.selectedId)
|
||||
: 0;
|
||||
const safeBaseIndex = baseIndex >= 0 ? baseIndex : 0;
|
||||
const nextIndex = safeBaseIndex + direction;
|
||||
|
||||
if (nextIndex < 0 || nextIndex >= current.notes.length) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const nextSelectedNote = current.notes[nextIndex];
|
||||
syncBaseInputsFromMemo(leafId, nextSelectedNote.body);
|
||||
return {
|
||||
...previous,
|
||||
[leafId]: {
|
||||
...current,
|
||||
selectedId: nextSelectedNote.id,
|
||||
draftBody: nextSelectedNote.body,
|
||||
isListOpen: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const setSelectValue = (leafId: string, nextCode?: string, nextItem?: SelectOptionItem) => {
|
||||
setSelectStates((previous) => ({
|
||||
...previous,
|
||||
[leafId]: {
|
||||
selectedCode: nextCode,
|
||||
selectedItem: nextItem ?? null,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const setEmptyPaneReadiness = (leafId: string, readiness: LayoutPreviewEmptyPaneState['readiness']) => {
|
||||
setEmptyPaneStates((previous) => ({
|
||||
...previous,
|
||||
[leafId]: {
|
||||
...(previous[leafId] ?? EMPTY_EMPTY_PANE_STATE),
|
||||
readiness,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const setEmptyPaneNote = (leafId: string, nextValue: string) => {
|
||||
setEmptyPaneStates((previous) => ({
|
||||
...previous,
|
||||
[leafId]: {
|
||||
...(previous[leafId] ?? EMPTY_EMPTY_PANE_STATE),
|
||||
note: nextValue,
|
||||
updatedAt: nextValue.trim() ? new Date().toISOString() : previous[leafId]?.updatedAt ?? null,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
memoStates,
|
||||
baseInputStates,
|
||||
selectStates,
|
||||
emptyPaneStates,
|
||||
setMemoDraftBody,
|
||||
toggleMemoList,
|
||||
saveMemoDraft,
|
||||
startMemoDraft,
|
||||
selectMemoNote,
|
||||
moveMemoSelection,
|
||||
deleteMemoSelection,
|
||||
setSelectValue,
|
||||
setEmptyPaneReadiness,
|
||||
setEmptyPaneNote,
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,22 @@ const PLAY_LAYOUTS_TABLE = 'play_layouts';
|
||||
|
||||
let setupPromise: Promise<void> | null = null;
|
||||
|
||||
function normalizeLayoutTimestamp(value: unknown, fallback: string) {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (trimmed) {
|
||||
const parsed = Date.parse(trimmed);
|
||||
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveWorkServerBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
@@ -172,11 +188,13 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
function toRecord(row: SavedLayoutRow): SavedLayoutRecord {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
createdAt: normalizeLayoutTimestamp(row.created_at, now),
|
||||
updatedAt: normalizeLayoutTimestamp(row.updated_at, now),
|
||||
axis: row.axis,
|
||||
sizeUnit: row.size_unit,
|
||||
primarySize: Number(row.primary_size),
|
||||
@@ -191,11 +209,15 @@ function toRecord(row: SavedLayoutRow): SavedLayoutRecord {
|
||||
}
|
||||
|
||||
function toRow(record: SavedLayoutRecord): SavedLayoutRow {
|
||||
const now = new Date().toISOString();
|
||||
const createdAt = normalizeLayoutTimestamp(record.createdAt, now);
|
||||
const updatedAt = normalizeLayoutTimestamp(record.updatedAt, createdAt);
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
created_at: record.createdAt,
|
||||
updated_at: record.updatedAt,
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
axis: record.axis,
|
||||
size_unit: record.sizeUnit,
|
||||
primary_size: record.primarySize,
|
||||
@@ -286,7 +308,20 @@ export async function saveLayout(record: SavedLayoutRecord) {
|
||||
await request<{ ok: boolean }>(`/crud/${PLAY_LAYOUTS_TABLE}/update`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
data: row,
|
||||
data: {
|
||||
name: row.name,
|
||||
updated_at: row.updated_at,
|
||||
axis: row.axis,
|
||||
size_unit: row.size_unit,
|
||||
primary_size: row.primary_size,
|
||||
primary_min: row.primary_min,
|
||||
secondary_min: row.secondary_min,
|
||||
resizable: row.resizable,
|
||||
selected_leaf_id: row.selected_leaf_id,
|
||||
total_panes: row.total_panes,
|
||||
summary: row.summary,
|
||||
tree: row.tree,
|
||||
},
|
||||
where: [{ field: 'id', operator: 'eq', value: record.id }],
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user