Files
ai-code-app/src/features/board/BoardPage.tsx
2026-04-21 03:33:23 +09:00

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>
);
}