796 lines
28 KiB
TypeScript
Executable File
796 lines
28 KiB
TypeScript
Executable File
import {
|
|
ArrowLeftOutlined,
|
|
CheckSquareOutlined,
|
|
CompressOutlined,
|
|
CopyOutlined,
|
|
DeleteOutlined,
|
|
EyeOutlined,
|
|
ExpandOutlined,
|
|
FileTextOutlined,
|
|
PlayCircleOutlined,
|
|
PlusOutlined,
|
|
SaveOutlined,
|
|
} from '@ant-design/icons';
|
|
import { Button, Card, Checkbox, Empty, Flex, Input, List, Segmented, Select, Space, Spin, Tag, Typography, message } from 'antd';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
|
import {
|
|
createBoardPost,
|
|
deleteBoardPost,
|
|
fetchBoardPosts,
|
|
receiveBoardPostAutomation,
|
|
setupBoard,
|
|
updateBoardPost,
|
|
} from './api';
|
|
import type { BoardAutomationType, BoardDraft, BoardPost } from './types';
|
|
|
|
const { Paragraph, Text, Title } = Typography;
|
|
const { TextArea } = Input;
|
|
|
|
const EMPTY_DRAFT: BoardDraft = {
|
|
id: null,
|
|
title: '',
|
|
content: '',
|
|
automationType: 'none',
|
|
};
|
|
|
|
const BOARD_AUTOMATION_TYPE_OPTIONS: Array<{ label: string; value: BoardAutomationType }> = [
|
|
{ label: '선택 안함', value: 'none' },
|
|
{ label: 'Plan', value: 'plan' },
|
|
{ label: 'Command 실행', value: 'command_execution' },
|
|
{ label: '비 소스작업', value: 'non_source_work' },
|
|
{ label: 'autoWorker', value: 'auto_worker' },
|
|
];
|
|
|
|
const BOARD_AUTOMATION_TYPE_LABELS = new Map(
|
|
BOARD_AUTOMATION_TYPE_OPTIONS.map((option) => [option.value, option.label] as const),
|
|
);
|
|
|
|
function formatDateTime(value: string) {
|
|
return new Date(value).toLocaleString('ko-KR', {
|
|
timeZone: 'Asia/Seoul',
|
|
});
|
|
}
|
|
|
|
async function copyText(value: string) {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(value);
|
|
return;
|
|
}
|
|
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = value;
|
|
textArea.setAttribute('readonly', '');
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.left = '-9999px';
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textArea);
|
|
}
|
|
|
|
function resolveBoardAutomationStatus(
|
|
draftId: number | null,
|
|
automationReceived: boolean,
|
|
draftDirty: boolean,
|
|
errorMessage: string | null,
|
|
) {
|
|
if (errorMessage) {
|
|
return {
|
|
color: 'error',
|
|
label: '접수 실패',
|
|
description: errorMessage,
|
|
};
|
|
}
|
|
|
|
if (!draftId) {
|
|
return {
|
|
color: 'default',
|
|
label: '저장 필요',
|
|
description: '저장 후 자동화 접수 가능',
|
|
};
|
|
}
|
|
|
|
if (draftDirty) {
|
|
return {
|
|
color: 'warning',
|
|
label: '저장 필요',
|
|
description: '변경 내용을 저장한 뒤 접수',
|
|
};
|
|
}
|
|
|
|
if (automationReceived) {
|
|
return {
|
|
color: 'processing',
|
|
label: '접수완료',
|
|
description: '연결된 Plan에서 작업 상태 확인',
|
|
};
|
|
}
|
|
|
|
return {
|
|
color: 'default',
|
|
label: '대기',
|
|
description: '자동화 접수 전',
|
|
};
|
|
}
|
|
|
|
function getBoardPostAutomationReceiveError(item: BoardPost, dirtyDraftId: number | null) {
|
|
if (item.id === dirtyDraftId) {
|
|
return '변경 내용을 저장한 뒤 자동화 접수하세요.';
|
|
}
|
|
|
|
if (item.automationReceivedAt || item.automationPlanItemId) {
|
|
return '이미 자동화 접수된 게시글입니다.';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function BoardPage() {
|
|
const [messageApi, contextHolder] = message.useMessage();
|
|
const [items, setItems] = useState<BoardPost[]>([]);
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
|
const [draft, setDraft] = useState<BoardDraft>(EMPTY_DRAFT);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [automationReceiving, setAutomationReceiving] = useState(false);
|
|
const [automationReceiveError, setAutomationReceiveError] = useState<string | null>(null);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
|
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
|
|
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
|
|
const [contentExpanded, setContentExpanded] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const mediaQuery = window.matchMedia('(max-width: 960px)');
|
|
const update = () => {
|
|
setIsMobileViewport(mediaQuery.matches);
|
|
if (!mediaQuery.matches) {
|
|
setMobileDetailOpen(false);
|
|
}
|
|
};
|
|
|
|
update();
|
|
mediaQuery.addEventListener('change', update);
|
|
|
|
return () => {
|
|
mediaQuery.removeEventListener('change', update);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
setErrorMessage(null);
|
|
|
|
try {
|
|
await setupBoard();
|
|
const nextItems = await fetchBoardPosts();
|
|
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setItems(nextItems);
|
|
setSelectedId((previous) => previous ?? nextItems[0]?.id ?? null);
|
|
} catch (error) {
|
|
if (!cancelled) {
|
|
setErrorMessage(error instanceof Error ? error.message : '게시판을 불러오지 못했습니다.');
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
void load();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const selectedItem = useMemo(
|
|
() => items.find((item) => item.id === selectedId) ?? null,
|
|
[items, selectedId],
|
|
);
|
|
const showMobileDetailOnly = isMobileViewport && mobileDetailOpen;
|
|
const automationReceived = Boolean(selectedItem?.automationReceivedAt || selectedItem?.automationPlanItemId);
|
|
const isDraftLocked = automationReceived;
|
|
const draftDirty = Boolean(
|
|
selectedItem &&
|
|
(
|
|
draft.title !== selectedItem.title ||
|
|
draft.content !== selectedItem.content ||
|
|
draft.automationType !== selectedItem.automationType
|
|
),
|
|
);
|
|
const dirtyDraftId = draftDirty && draft.id ? draft.id : null;
|
|
const automationStatus = resolveBoardAutomationStatus(draft.id, automationReceived, draftDirty, automationReceiveError);
|
|
const automationTypeLabel = BOARD_AUTOMATION_TYPE_LABELS.get(draft.automationType) ?? draft.automationType;
|
|
const receivableIds = useMemo(
|
|
() =>
|
|
items
|
|
.filter((item) => !getBoardPostAutomationReceiveError(item, dirtyDraftId))
|
|
.map((item) => item.id),
|
|
[dirtyDraftId, items],
|
|
);
|
|
const receivableIdSet = useMemo(() => new Set(receivableIds), [receivableIds]);
|
|
const checkedReceivableCount = checkedIds.filter((id) => receivableIdSet.has(id)).length;
|
|
const allReceivableChecked = receivableIds.length > 0 && checkedReceivableCount === receivableIds.length;
|
|
const partiallyChecked = checkedReceivableCount > 0 && checkedReceivableCount < receivableIds.length;
|
|
|
|
useEffect(() => {
|
|
if (selectedItem) {
|
|
setDraft({
|
|
id: selectedItem.id,
|
|
title: selectedItem.title,
|
|
content: selectedItem.content,
|
|
automationType: selectedItem.automationType,
|
|
});
|
|
setAutomationReceiveError(null);
|
|
return;
|
|
}
|
|
|
|
setDraft(EMPTY_DRAFT);
|
|
setAutomationReceiveError(null);
|
|
}, [selectedItem]);
|
|
|
|
useEffect(() => {
|
|
const itemIdSet = new Set(items.map((item) => item.id));
|
|
setCheckedIds((previous) => previous.filter((id) => itemIdSet.has(id)));
|
|
}, [items]);
|
|
|
|
const handleCreateDraft = () => {
|
|
setSelectedId(null);
|
|
setDraft(EMPTY_DRAFT);
|
|
setAutomationReceiveError(null);
|
|
setMobileDetailOpen(isMobileViewport);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
const normalizedTitle = draft.title.trim();
|
|
const normalizedContent = draft.content.trim();
|
|
|
|
if (!normalizedTitle) {
|
|
messageApi.warning('제목을 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
if (!normalizedContent) {
|
|
messageApi.warning('본문을 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
setAutomationReceiveError(null);
|
|
|
|
try {
|
|
const savedItem = draft.id
|
|
? await updateBoardPost({
|
|
...draft,
|
|
title: normalizedTitle,
|
|
content: normalizedContent,
|
|
})
|
|
: await createBoardPost({
|
|
...draft,
|
|
title: normalizedTitle,
|
|
content: normalizedContent,
|
|
});
|
|
|
|
setItems((previous) => {
|
|
const filtered = previous.filter((item) => item.id !== savedItem.id);
|
|
return [savedItem, ...filtered];
|
|
});
|
|
setSelectedId(savedItem.id);
|
|
setMobileDetailOpen(isMobileViewport);
|
|
messageApi.success(draft.id ? '게시글을 수정했습니다.' : '게시글을 등록했습니다.');
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '게시글 저장에 실패했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!draft.id) {
|
|
handleCreateDraft();
|
|
return;
|
|
}
|
|
|
|
setDeleting(true);
|
|
|
|
try {
|
|
await deleteBoardPost(draft.id);
|
|
setItems((previous) => previous.filter((item) => item.id !== draft.id));
|
|
setSelectedId((previous) => (previous === draft.id ? null : previous));
|
|
setDraft(EMPTY_DRAFT);
|
|
setMobileDetailOpen(false);
|
|
messageApi.success('게시글을 삭제했습니다.');
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '게시글 삭제에 실패했습니다.');
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
};
|
|
|
|
const handleCopyContent = async () => {
|
|
if (!draft.content.trim()) {
|
|
messageApi.warning('복사할 본문이 없습니다.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await copyText(draft.content);
|
|
messageApi.success('본문을 복사했습니다.');
|
|
} catch (error) {
|
|
messageApi.error(error instanceof Error ? error.message : '본문 복사에 실패했습니다.');
|
|
}
|
|
};
|
|
|
|
const handleAutomationReceive = async () => {
|
|
if (!draft.id) {
|
|
messageApi.warning('저장된 게시글만 자동화 접수할 수 있습니다.');
|
|
return;
|
|
}
|
|
|
|
if (draftDirty) {
|
|
messageApi.warning('변경 내용을 저장한 뒤 자동화 접수하세요.');
|
|
return;
|
|
}
|
|
|
|
if (automationReceived) {
|
|
messageApi.info('이미 자동화 접수된 게시글입니다.');
|
|
return;
|
|
}
|
|
|
|
setAutomationReceiving(true);
|
|
setAutomationReceiveError(null);
|
|
|
|
try {
|
|
const result = await receiveBoardPostAutomation(draft.id);
|
|
setItems((previous) => previous.map((item) => (item.id === result.item.id ? result.item : item)));
|
|
messageApi.success(
|
|
result.alreadyReceived
|
|
? '이미 자동화 접수된 게시글입니다.'
|
|
: `자동화 접수했습니다. Plan #${result.planItemId}`,
|
|
);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : '자동화 접수에 실패했습니다.';
|
|
setAutomationReceiveError(message);
|
|
messageApi.error(message);
|
|
} finally {
|
|
setAutomationReceiving(false);
|
|
}
|
|
};
|
|
|
|
const handleBulkAutomationReceive = async () => {
|
|
if (!checkedIds.length) {
|
|
messageApi.warning('접수할 게시글을 선택하세요.');
|
|
return;
|
|
}
|
|
|
|
const targetItems = items.filter((item) => checkedIds.includes(item.id));
|
|
const receivableItems = targetItems.filter((item) => !getBoardPostAutomationReceiveError(item, dirtyDraftId));
|
|
|
|
if (!receivableItems.length) {
|
|
messageApi.warning('선택한 게시글 중 자동화 접수할 수 있는 항목이 없습니다.');
|
|
return;
|
|
}
|
|
|
|
setAutomationReceiving(true);
|
|
setAutomationReceiveError(null);
|
|
|
|
const updatedItems = new Map<number, BoardPost>();
|
|
const alreadyReceivedIds: number[] = [];
|
|
const failedItems: Array<{ id: number; message: string }> = [];
|
|
|
|
try {
|
|
for (const item of receivableItems) {
|
|
try {
|
|
const result = await receiveBoardPostAutomation(item.id);
|
|
updatedItems.set(result.item.id, result.item);
|
|
|
|
if (result.alreadyReceived) {
|
|
alreadyReceivedIds.push(item.id);
|
|
}
|
|
} catch (error) {
|
|
failedItems.push({
|
|
id: item.id,
|
|
message: error instanceof Error ? error.message : '자동화 접수에 실패했습니다.',
|
|
});
|
|
}
|
|
}
|
|
|
|
if (updatedItems.size > 0) {
|
|
setItems((previous) => previous.map((item) => updatedItems.get(item.id) ?? item));
|
|
}
|
|
|
|
setCheckedIds((previous) => previous.filter((id) => !updatedItems.has(id)));
|
|
|
|
const receivedCount = updatedItems.size - alreadyReceivedIds.length;
|
|
const summaryParts = [
|
|
receivedCount > 0 ? `${receivedCount}건 접수` : null,
|
|
alreadyReceivedIds.length > 0 ? `${alreadyReceivedIds.length}건 기존 접수` : null,
|
|
failedItems.length > 0 ? `${failedItems.length}건 실패` : null,
|
|
].filter(Boolean);
|
|
|
|
if (failedItems.length > 0) {
|
|
messageApi.warning(`선택 접수 결과: ${summaryParts.join(', ')}.`);
|
|
} else {
|
|
messageApi.success(`선택한 게시글을 처리했습니다. ${summaryParts.join(', ')}.`);
|
|
}
|
|
|
|
if (selectedItem) {
|
|
const selectedFailure = failedItems.find((entry) => entry.id === selectedItem.id);
|
|
setAutomationReceiveError(selectedFailure?.message ?? null);
|
|
}
|
|
} finally {
|
|
setAutomationReceiving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Space direction="vertical" size={16} className="board-page">
|
|
{contextHolder}
|
|
<Card className="board-page__card" bordered={false}>
|
|
<Flex justify="space-between" align="center" gap={16} wrap>
|
|
<div>
|
|
<Title level={4} className="board-page__title">
|
|
Plan
|
|
</Title>
|
|
<Paragraph className="board-page__copy">
|
|
마크다운 본문을 입력하고 즉시 프리뷰를 확인한 뒤 DB에 저장합니다.
|
|
</Paragraph>
|
|
</div>
|
|
<Space wrap>
|
|
<Button icon={<PlusOutlined />} onClick={handleCreateDraft}>
|
|
새 글
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
icon={<SaveOutlined />}
|
|
loading={saving}
|
|
disabled={isDraftLocked}
|
|
onClick={() => {
|
|
void handleSave();
|
|
}}
|
|
>
|
|
저장
|
|
</Button>
|
|
</Space>
|
|
</Flex>
|
|
</Card>
|
|
|
|
{errorMessage ? (
|
|
<Card className="board-page__card" bordered={false}>
|
|
<Text type="danger">{errorMessage}</Text>
|
|
</Card>
|
|
) : null}
|
|
|
|
<div className="board-page__grid">
|
|
<Card
|
|
title={`게시글 목록 (${items.length})`}
|
|
className={`board-page__card board-page__list-card${
|
|
showMobileDetailOnly ? ' board-page__list-card--mobile-hidden' : ''
|
|
}`}
|
|
bordered={false}
|
|
extra={
|
|
<Space size={8} wrap>
|
|
{loading ? <Spin size="small" /> : null}
|
|
<Text type="secondary" className="board-page__bulk-count">
|
|
선택 {checkedReceivableCount}건
|
|
</Text>
|
|
<Checkbox
|
|
indeterminate={partiallyChecked}
|
|
checked={allReceivableChecked}
|
|
disabled={!receivableIds.length}
|
|
onChange={(event) => {
|
|
setCheckedIds((previous) => {
|
|
if (event.target.checked) {
|
|
return Array.from(new Set([...previous, ...receivableIds]));
|
|
}
|
|
|
|
return previous.filter((id) => !receivableIdSet.has(id));
|
|
});
|
|
}}
|
|
>
|
|
대기 전체
|
|
</Checkbox>
|
|
<Button
|
|
icon={<CheckSquareOutlined />}
|
|
loading={automationReceiving}
|
|
disabled={!checkedReceivableCount}
|
|
onClick={() => {
|
|
void handleBulkAutomationReceive();
|
|
}}
|
|
>
|
|
선택 접수
|
|
</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
{loading ? (
|
|
<div className="board-page__loading">
|
|
<Spin />
|
|
</div>
|
|
) : items.length ? (
|
|
<List
|
|
dataSource={items}
|
|
renderItem={(item) => (
|
|
<List.Item
|
|
className={item.id === selectedId ? 'board-page__list-item is-active' : 'board-page__list-item'}
|
|
onClick={() => {
|
|
setSelectedId(item.id);
|
|
if (isMobileViewport) {
|
|
setMobileDetailOpen(true);
|
|
}
|
|
}}
|
|
>
|
|
<List.Item.Meta
|
|
avatar={
|
|
<Checkbox
|
|
checked={checkedIds.includes(item.id)}
|
|
disabled={Boolean(getBoardPostAutomationReceiveError(item, dirtyDraftId))}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
}}
|
|
onChange={(event) => {
|
|
setCheckedIds((previous) =>
|
|
event.target.checked
|
|
? Array.from(new Set([...previous, item.id]))
|
|
: previous.filter((checkedId) => checkedId !== item.id),
|
|
);
|
|
}}
|
|
/>
|
|
}
|
|
title={
|
|
<Flex justify="space-between" align="center" gap={8}>
|
|
<Flex align="center" gap={8} className="board-page__list-title">
|
|
<FileTextOutlined className="board-page__list-icon" />
|
|
<Text strong>{item.title}</Text>
|
|
</Flex>
|
|
<Space size={6} wrap>
|
|
{item.id === dirtyDraftId ? <Tag color="warning">저장 필요</Tag> : null}
|
|
<Tag color={item.automationReceivedAt || item.automationPlanItemId ? 'processing' : 'default'}>
|
|
{item.automationReceivedAt || item.automationPlanItemId ? '접수완료' : '대기'}
|
|
</Tag>
|
|
</Space>
|
|
</Flex>
|
|
}
|
|
description={
|
|
<Space direction="vertical" size={4}>
|
|
<Text type="secondary" className="board-page__list-preview">
|
|
{item.preview || '본문 미리보기가 없습니다.'}
|
|
</Text>
|
|
<Text type="secondary">수정 {formatDateTime(item.updatedAt)}</Text>
|
|
</Space>
|
|
}
|
|
/>
|
|
</List.Item>
|
|
)}
|
|
/>
|
|
) : (
|
|
<Empty description="등록된 게시글이 없습니다." />
|
|
)}
|
|
</Card>
|
|
|
|
<div
|
|
className={`board-page__editor-column${
|
|
isMobileViewport && !mobileDetailOpen ? ' board-page__editor-column--mobile-hidden' : ''
|
|
}`}
|
|
>
|
|
<Card
|
|
title={draft.id ? `게시글 #${draft.id}` : '새 게시글'}
|
|
className="board-page__card board-page__editor-card"
|
|
bordered={false}
|
|
extra={
|
|
<Space wrap>
|
|
{isMobileViewport && mobileDetailOpen ? (
|
|
<Button
|
|
icon={<ArrowLeftOutlined />}
|
|
onClick={() => {
|
|
setMobileDetailOpen(false);
|
|
}}
|
|
>
|
|
목록
|
|
</Button>
|
|
) : null}
|
|
{draft.id ? <Tag color={automationStatus.color}>{automationStatus.label}</Tag> : null}
|
|
{draft.id && selectedItem?.automationPlanItemId ? (
|
|
<Button
|
|
href={`/plans/all?planId=${selectedItem.automationPlanItemId}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
연결자동화
|
|
</Button>
|
|
) : null}
|
|
{draft.id ? (
|
|
<Button
|
|
icon={<PlayCircleOutlined />}
|
|
loading={automationReceiving}
|
|
disabled={automationReceived && !automationReceiveError}
|
|
onClick={() => {
|
|
void handleAutomationReceive();
|
|
}}
|
|
>
|
|
{automationReceived ? '자동화 접수됨' : automationReceiveError ? '접수 재시도' : '자동화 접수'}
|
|
</Button>
|
|
) : null}
|
|
{draft.id ? (
|
|
<Button
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
loading={deleting}
|
|
disabled={isDraftLocked}
|
|
onClick={() => {
|
|
void handleDelete();
|
|
}}
|
|
>
|
|
삭제
|
|
</Button>
|
|
) : null}
|
|
</Space>
|
|
}
|
|
>
|
|
<Space direction="vertical" size={16} className="board-page__editor">
|
|
<Input
|
|
size="large"
|
|
placeholder="제목을 입력하세요"
|
|
value={draft.title}
|
|
readOnly={isDraftLocked}
|
|
onChange={(event) => {
|
|
setDraft((previous) => ({
|
|
...previous,
|
|
title: event.target.value,
|
|
}));
|
|
}}
|
|
/>
|
|
<Flex gap={8} wrap>
|
|
<Tag color={automationStatus.color}>{automationStatus.label}</Tag>
|
|
{automationStatus.description ? <Text type="secondary">{automationStatus.description}</Text> : null}
|
|
</Flex>
|
|
<div className="board-page__automation-field">
|
|
<Text strong>자동화 처리</Text>
|
|
{automationReceived ? (
|
|
<div className="board-page__automation-readonly" aria-readonly="true">
|
|
<Text>{automationTypeLabel}</Text>
|
|
<Tag color="processing">접수 후 읽기전용</Tag>
|
|
</div>
|
|
) : (
|
|
<Select
|
|
className="board-page__automation-select"
|
|
value={draft.automationType}
|
|
options={BOARD_AUTOMATION_TYPE_OPTIONS}
|
|
popupClassName="board-page__automation-select-popup"
|
|
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
|
|
disabled={isDraftLocked}
|
|
onChange={(automationType) => {
|
|
setDraft((previous) => ({
|
|
...previous,
|
|
automationType,
|
|
}));
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`}>
|
|
{contentExpanded ? (
|
|
<Flex justify="space-between" align="center" gap={12} className="board-page__editor-toolbar">
|
|
<Text strong>본문 전체화면</Text>
|
|
<Space size={8}>
|
|
<Button
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
aria-label="본문 복사"
|
|
title="본문 복사"
|
|
onClick={() => {
|
|
void handleCopyContent();
|
|
}}
|
|
>
|
|
복사
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
icon={<CompressOutlined />}
|
|
aria-label="본문 최대화 해제"
|
|
onClick={() => {
|
|
setContentExpanded(false);
|
|
}}
|
|
>
|
|
닫기
|
|
</Button>
|
|
</Space>
|
|
</Flex>
|
|
) : null}
|
|
<Segmented
|
|
className="board-page__mobile-toggle"
|
|
options={[
|
|
{ label: '편집', value: 'edit', icon: <FileTextOutlined /> },
|
|
{ label: '미리보기', value: 'preview', icon: <EyeOutlined /> },
|
|
]}
|
|
value={mobileView}
|
|
onChange={(value) => {
|
|
setMobileView(value as 'edit' | 'preview');
|
|
}}
|
|
/>
|
|
<Flex justify="space-between" align="center" gap={8}>
|
|
<Text type="secondary">본문</Text>
|
|
<Space size={8}>
|
|
<Button
|
|
icon={<CopyOutlined />}
|
|
aria-label="본문 복사"
|
|
title="본문 복사"
|
|
onClick={() => {
|
|
void handleCopyContent();
|
|
}}
|
|
/>
|
|
<Button
|
|
icon={contentExpanded ? <CompressOutlined /> : <ExpandOutlined />}
|
|
aria-label={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
|
|
title={contentExpanded ? '본문 최대화 해제' : '본문 최대화'}
|
|
onClick={() => {
|
|
setContentExpanded((previous) => !previous);
|
|
}}
|
|
/>
|
|
</Space>
|
|
</Flex>
|
|
<div
|
|
className={`board-page__preview-grid${contentExpanded ? ' board-page__preview-grid--expanded' : ''}`}
|
|
>
|
|
<div
|
|
className={`board-page__pane${mobileView === 'preview' ? ' board-page__pane--mobile-hidden' : ''}${
|
|
contentExpanded ? ' board-page__pane--expanded' : ''
|
|
}`}
|
|
>
|
|
<TextArea
|
|
value={draft.content}
|
|
placeholder={'# 제목\n\n마크다운 본문을 입력하세요.\n\n- 목록\n- 코드블록\n- 링크'}
|
|
readOnly={isDraftLocked}
|
|
onChange={(event) => {
|
|
setDraft((previous) => ({
|
|
...previous,
|
|
content: event.target.value,
|
|
}));
|
|
}}
|
|
className={`board-page__textarea${contentExpanded ? ' board-page__textarea--expanded' : ''}`}
|
|
/>
|
|
</div>
|
|
<div
|
|
className={`board-page__pane${mobileView === 'edit' ? ' board-page__pane--mobile-hidden' : ''}${
|
|
contentExpanded ? ' board-page__pane--expanded' : ''
|
|
}`}
|
|
>
|
|
<div className={`board-page__preview${contentExpanded ? ' board-page__preview--expanded' : ''}`}>
|
|
<div className="board-page__preview-content">
|
|
{draft.content.trim() ? (
|
|
<MarkdownPreviewContent content={draft.content} />
|
|
) : (
|
|
<Empty description="미리보기할 본문이 없습니다." image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isDraftLocked ? (
|
|
<Text type="secondary">자동화 접수된 작업메모는 게시판에서 수정하거나 삭제할 수 없습니다.</Text>
|
|
) : null}
|
|
</div>
|
|
</Space>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</Space>
|
|
);
|
|
}
|