chore: sync local workspace changes
This commit is contained in:
426
src/app/main/ChatDefaultContextManagementPage.tsx
Normal file
426
src/app/main/ChatDefaultContextManagementPage.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
ArrowsAltOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
SaveOutlined,
|
||||
ShrinkOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
deleteChatDefaultContext,
|
||||
pruneChatRoomContextSettings,
|
||||
pruneChatTypeDefaultSelections,
|
||||
upsertChatDefaultContext,
|
||||
useChatContextSettingsRegistry,
|
||||
type ChatDefaultContextRecord,
|
||||
} from './chatContextSettingsAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
type ChatDefaultContextFormValue = {
|
||||
id?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM_VALUE: ChatDefaultContextFormValue = {
|
||||
title: '',
|
||||
content: '',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
function toFormValue(record: ChatDefaultContextRecord | null): ChatDefaultContextFormValue {
|
||||
if (!record) {
|
||||
return EMPTY_FORM_VALUE;
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
content: record.content,
|
||||
enabled: record.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatDefaultContextManagementPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const {
|
||||
defaultContexts,
|
||||
chatTypeDefaults,
|
||||
roomContexts,
|
||||
errorMessage: contextSettingsErrorMessage,
|
||||
setStore,
|
||||
} = useChatContextSettingsRegistry();
|
||||
const [selectedContextId, setSelectedContextId] = useState<string | null>(defaultContexts[0]?.id ?? null);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
|
||||
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
|
||||
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [form] = Form.useForm<ChatDefaultContextFormValue>();
|
||||
|
||||
const selectedContext = useMemo(
|
||||
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
|
||||
[defaultContexts, selectedContextId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedContextId && defaultContexts.some((item) => item.id === selectedContextId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedContextId(defaultContexts[0]?.id ?? null);
|
||||
}, [defaultContexts, selectedContextId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
return;
|
||||
}
|
||||
|
||||
form.resetFields();
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedContext));
|
||||
}, [detailMode, form, isCreating, selectedContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 960px)');
|
||||
const update = () => {
|
||||
setIsMobileViewport(mediaQuery.matches);
|
||||
if (!mediaQuery.matches) {
|
||||
setMobileView('edit');
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
mediaQuery.addEventListener('change', update);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openCreateForm = () => {
|
||||
setIsCreating(true);
|
||||
setSelectedContextId(null);
|
||||
setDetailMode('detail');
|
||||
setMaximizedPane('none');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
};
|
||||
|
||||
const openDetail = (contextId: string) => {
|
||||
setIsCreating(false);
|
||||
setSelectedContextId(contextId);
|
||||
setDetailMode('detail');
|
||||
setMaximizedPane('none');
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
setMaximizedPane('none');
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedContext.title}" 기본 유형을 삭제할까요?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextDefaultContexts = deleteChatDefaultContext(defaultContexts, selectedContext.id);
|
||||
const nextChatTypeDefaults = pruneChatTypeDefaultSelections(chatTypeDefaults, selectedContext.id);
|
||||
const nextRoomContexts = pruneChatRoomContextSettings(roomContexts, selectedContext.id);
|
||||
|
||||
await setStore({
|
||||
defaultContexts: nextDefaultContexts,
|
||||
chatTypeDefaults: nextChatTypeDefaults,
|
||||
roomContexts: nextRoomContexts,
|
||||
});
|
||||
setSelectedContextId(nextDefaultContexts[0]?.id ?? null);
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card title="기본 유형 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 기본 유형을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page--pane-maximized' : ''
|
||||
}`}
|
||||
>
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="기본 유형 관리"
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 기본 유형
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 기본 유형</Title>
|
||||
<Text type="secondary">{`${defaultContexts.length}건`}</Text>
|
||||
</div>
|
||||
{defaultContexts.length > 0 ? (
|
||||
<List
|
||||
dataSource={defaultContexts}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
className={
|
||||
item.id === selectedContextId
|
||||
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
||||
: 'chat-type-management-page__item'
|
||||
}
|
||||
onClick={() => {
|
||||
openDetail(item.id);
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(item.id);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className="chat-type-management-page__item-main">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.title}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
</Space>
|
||||
<div className="chat-type-management-page__item-description">
|
||||
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 기본 유형이 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
title={isCreating ? '기본 유형 등록' : '기본 유형 상세'}
|
||||
className={`chat-type-management-page__card${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page__card--pane-maximized' : ''
|
||||
}`}
|
||||
extra={
|
||||
<Space size={6} className="chat-type-management-page__header-actions" wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<SaveOutlined />}
|
||||
aria-label={isCreating ? '등록' : '수정 저장'}
|
||||
onClick={() => {
|
||||
void form.submit();
|
||||
}}
|
||||
/>
|
||||
<Button shape="circle" icon={<PlusOutlined />} aria-label="새 입력" onClick={openCreateForm} />
|
||||
{!isCreating && selectedContext ? (
|
||||
<Button
|
||||
danger
|
||||
shape="circle"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label="삭제"
|
||||
onClick={() => {
|
||||
void handleDelete();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__editor">
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
|
||||
<Form
|
||||
className="chat-type-management-page__editor-form"
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={EMPTY_FORM_VALUE}
|
||||
onFinish={async (values) => {
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
const nextDefaultContexts = upsertChatDefaultContext(defaultContexts, values);
|
||||
const savedContext = nextDefaultContexts.find((item) => item.id === values.id || item.title === values.title) ?? null;
|
||||
|
||||
await setStore({
|
||||
defaultContexts: nextDefaultContexts,
|
||||
chatTypeDefaults,
|
||||
roomContexts,
|
||||
});
|
||||
setIsCreating(false);
|
||||
setSelectedContextId(savedContext?.id ?? null);
|
||||
setDetailMode('detail');
|
||||
} catch (error) {
|
||||
setSaveErrorMessage(error instanceof Error ? error.message : '기본 유형 저장에 실패했습니다.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item name="id" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="chat-type-management-page__editor-scroll">
|
||||
<div className={`chat-type-management-page__meta-grid${maximizedPane !== 'none' ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
|
||||
label="기본 유형명"
|
||||
name="title"
|
||||
rules={[{ required: true, message: '기본 유형명을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="예: 모바일 검증 공통 규칙" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
|
||||
label="사용 여부"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-field">
|
||||
<Text strong className="chat-type-management-page__field-label">
|
||||
기본 유형 본문
|
||||
</Text>
|
||||
<div className="chat-type-management-page__markdown-editor">
|
||||
<div className="chat-type-management-page__editor-toolbar">
|
||||
{isMobileViewport ? (
|
||||
<Segmented
|
||||
className="chat-type-management-page__mobile-toggle"
|
||||
options={[
|
||||
{ label: '입력', value: 'edit' },
|
||||
{ label: '미리보기', value: 'preview' },
|
||||
]}
|
||||
value={mobileView}
|
||||
onChange={(value) => {
|
||||
setMobileView(value as 'edit' | 'preview');
|
||||
setMaximizedPane('none');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
type={maximizedPane === 'edit' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
|
||||
</Button>
|
||||
<Button
|
||||
type={maximizedPane === 'preview' ? 'primary' : 'default'}
|
||||
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
|
||||
onClick={() => {
|
||||
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
|
||||
}}
|
||||
>
|
||||
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-grid${
|
||||
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'preview'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">입력</Text>
|
||||
</div>
|
||||
<Form.Item name="content" noStyle>
|
||||
<Input.TextArea
|
||||
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
|
||||
className="chat-type-management-page__markdown-textarea"
|
||||
placeholder={'## 적용 기준\n- 기본 유형의 공통 규칙을 Markdown으로 정의하세요.'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div
|
||||
className={`chat-type-management-page__markdown-pane${
|
||||
isMobileViewport && mobileView === 'edit'
|
||||
? ' chat-type-management-page__markdown-pane--mobile-hidden'
|
||||
: ''
|
||||
}${
|
||||
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chat-type-management-page__markdown-preview">
|
||||
<div className="chat-type-management-page__markdown-pane-header">
|
||||
<Text type="secondary">미리보기</Text>
|
||||
</div>
|
||||
<div className="chat-type-management-page__markdown-preview-body">
|
||||
<Form.Item noStyle shouldUpdate={(prev, next) => prev.content !== next.content}>
|
||||
{({ getFieldValue }) => {
|
||||
const content = String(getFieldValue('content') ?? '').trim();
|
||||
|
||||
return content ? (
|
||||
<MarkdownPreviewContent content={content} />
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 기본 유형 본문이 없습니다." />
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user