diff --git a/Elevate.Admin/src/components/editor/AttachUploader.jsx b/Elevate.Admin/src/components/editor/AttachUploader.jsx
index f0c7131..27b61ef 100644
--- a/Elevate.Admin/src/components/editor/AttachUploader.jsx
+++ b/Elevate.Admin/src/components/editor/AttachUploader.jsx
@@ -17,8 +17,13 @@ const ATTACH_MIME_MAP = {
'.zip': 'application/zip',
'.xls': 'application/vnd.ms-excel',
'.doc': 'application/msword',
+ '.ppt': 'application/vnd.ms-powerpoint',
+ '.hwp': 'application/x-hwp',
+ '.hwpx': 'application/vnd.hancom.hwpx',
}
+const ATTACH_EXTENSIONS = Object.keys(ATTACH_MIME_MAP)
+
/** 첨부파일 최대 크기: 50MB */
const MAX_ATTACH_BYTES = 50 * 1024 * 1024
@@ -27,6 +32,13 @@ function getContentType(file) {
return ATTACH_MIME_MAP[ext] || file.type || 'application/octet-stream'
}
+function getUploadErrorMessage(error) {
+ const message = String(error?.message || '').trim()
+ if (!message) return '업로드에 실패했습니다.'
+ const trimmed = message.length > 180 ? `${message.slice(0, 180)}...` : message
+ return `업로드에 실패했습니다. (${trimmed})`
+}
+
export default function AttachUploader({ postId, draftSessionId, onUploadingChange }) {
const { msalInstance } = useAuth()
const inputRef = useRef(null)
@@ -76,7 +88,7 @@ export default function AttachUploader({ postId, draftSessionId, onUploadingChan
const contentType = getContentType(file)
if (!Object.values(ATTACH_MIME_MAP).includes(contentType)) {
- setError('지원하지 않는 파일 형식입니다. (.docx .xlsx .pptx .pdf .csv .zip .xls .doc)')
+ setError(`지원하지 않는 파일 형식입니다. (${ATTACH_EXTENSIONS.join(' ')})`)
return
}
@@ -104,8 +116,8 @@ export default function AttachUploader({ postId, draftSessionId, onUploadingChan
const signedUrl = result?.signedUrl || null
setFiles(prev => [...prev, { id: result.fileId, fileName: uploadFile.name, blobUrl, signedUrl, isDeleting: false }])
setStatus('done')
- } catch {
- setError('업로드에 실패했습니다.')
+ } catch (err) {
+ setError(getUploadErrorMessage(err))
setStatus('error')
} finally {
onUploadingChange?.(false)
@@ -139,7 +151,7 @@ export default function AttachUploader({ postId, draftSessionId, onUploadingChan
@@ -154,7 +166,7 @@ export default function AttachUploader({ postId, draftSessionId, onUploadingChan
{canUpload
- ? 'docx · xlsx · pptx · pdf · csv · zip · xls · doc (최대 50MB)'
+ ? `${ATTACH_EXTENSIONS.map((ext) => ext.slice(1)).join(' · ')} (최대 50MB)`
: '첨부파일 업로드 준비 중입니다'}
diff --git a/Elevate.Server/src/controllers/adminController.js b/Elevate.Server/src/controllers/adminController.js
index 06e9084..d521ede 100644
--- a/Elevate.Server/src/controllers/adminController.js
+++ b/Elevate.Server/src/controllers/adminController.js
@@ -23,11 +23,16 @@ const allowedAttachMimeTypes = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/pdf',
- 'text/csv',
- 'application/zip',
- 'application/vnd.ms-excel',
- 'application/msword'
-]);
+ 'text/csv',
+ 'application/zip',
+ 'application/vnd.ms-excel',
+ 'application/msword',
+ 'application/vnd.ms-powerpoint',
+ 'application/x-hwp',
+ 'application/haansofthwp',
+ 'application/vnd.hancom.hwp',
+ 'application/vnd.hancom.hwpx'
+]);
const maxAttachSizeBytes = 50 * 1024 * 1024;
const attachCategoryPartition = '_attach';
const draftAttachTtlMs = 24 * 60 * 60 * 1000;
@@ -900,11 +905,13 @@ exports.createFileMetadata = async (req, res) => {
contentType,
sizeBytes,
fileName: trimmedFileName,
- expiresAt: isDraftAttachment ? getDraftAttachmentExpiresAt(new Date(now)) : null,
- ttl: isDraftAttachment ? Math.floor(draftAttachTtlMs / 1000) : null,
createdAt: now,
updatedAt: now
};
+ if (isDraftAttachment) {
+ fileDocument.expiresAt = getDraftAttachmentExpiresAt(new Date(now));
+ fileDocument.ttl = Math.floor(draftAttachTtlMs / 1000);
+ }
await container.items.create(fileDocument);
@@ -951,10 +958,10 @@ exports.linkDraftAttachmentsToPost = async (req, res) => {
...file,
postId: normalizedPostId,
draftSessionId: null,
- expiresAt: null,
- ttl: null,
updatedAt: new Date().toISOString()
};
+ delete updated.expiresAt;
+ delete updated.ttl;
await container.item(file.id, file.category || file.partitionKey || attachCategoryPartition).replace(updated);
}
diff --git a/Elevate.Server/tests/admin-attachments.test.js b/Elevate.Server/tests/admin-attachments.test.js
index 486aca9..2ac65ca 100644
--- a/Elevate.Server/tests/admin-attachments.test.js
+++ b/Elevate.Server/tests/admin-attachments.test.js
@@ -249,6 +249,46 @@ test('createFileMetadata stores trimmed postId and clears draftSessionId for sav
assert.equal(res.getStatus(), 201);
assert.equal(createdFileDocument.postId, 'post-1');
assert.equal(createdFileDocument.draftSessionId, null);
+ assert.equal(Object.hasOwn(createdFileDocument, 'ttl'), false);
+ assert.equal(Object.hasOwn(createdFileDocument, 'expiresAt'), false);
+});
+
+test('createFileMetadata accepts PowerPoint and Hangul attachment formats', async () => {
+ const cases = [
+ {
+ blobUrl: 'https://account.blob.core.windows.net/attachments/attach/2026/06/slides.ppt',
+ contentType: 'application/vnd.ms-powerpoint',
+ fileName: 'slides.ppt'
+ },
+ {
+ blobUrl: 'https://account.blob.core.windows.net/attachments/attach/2026/06/report.hwp',
+ contentType: 'application/x-hwp',
+ fileName: 'report.hwp'
+ },
+ {
+ blobUrl: 'https://account.blob.core.windows.net/attachments/attach/2026/06/report.hwpx',
+ contentType: 'application/vnd.hancom.hwpx',
+ fileName: 'report.hwpx'
+ }
+ ];
+
+ for (const attachment of cases) {
+ createdFileDocument = null;
+ const res = makeRes();
+
+ await createFileMetadata({
+ body: {
+ postId: 'post-1',
+ ...attachment,
+ sizeBytes: 1234
+ },
+ correlationId: 'x'
+ }, res);
+
+ assert.equal(res.getStatus(), 201);
+ assert.equal(createdFileDocument.contentType, attachment.contentType);
+ assert.equal(createdFileDocument.fileName, attachment.fileName);
+ }
});
test('createFileMetadata stores expiry and removes stale draft attachments', async () => {
@@ -400,11 +440,12 @@ test('linkDraftAttachmentsToPost links draft attachments and clears draftSession
id: doc.id,
postId: doc.postId,
draftSessionId: doc.draftSessionId,
- ttl: doc.ttl
+ hasTtl: Object.hasOwn(doc, 'ttl'),
+ hasExpiresAt: Object.hasOwn(doc, 'expiresAt')
})),
[
- { id: 'file-1', postId: 'post-1', draftSessionId: null, ttl: null },
- { id: 'file-2', postId: 'post-1', draftSessionId: null, ttl: null }
+ { id: 'file-1', postId: 'post-1', draftSessionId: null, hasTtl: false, hasExpiresAt: false },
+ { id: 'file-2', postId: 'post-1', draftSessionId: null, hasTtl: false, hasExpiresAt: false }
]
);
assert.match(replacedFileDocuments[0].doc.updatedAt, /^\d{4}-\d{2}-\d{2}T/);
diff --git a/Elevate.Web/README.md b/Elevate.Web/README.md
index 8955fa2..5f402db 100644
--- a/Elevate.Web/README.md
+++ b/Elevate.Web/README.md
@@ -1,7 +1,7 @@
# Elevate Web
-Elevate 공개 블로그 SPA입니다. React + Vite 기반으로 빌드되며, Azure Static Web Apps(`swa-elv-web-test`)에 배포됩니다.
+Elevate 공개 블로그 SPA입니다. React + Vite 기반으로 빌드되며, 운영 환경은 GitHub Pages(`https://microsoft-elevate.com`)에 배포됩니다. Azure Static Web Apps(`swa-elv-web-test`)는 테스트/보조 환경입니다.
## 빠른 시작
@@ -22,12 +22,10 @@ npm run build
| 문서 | 위치 |
|------|------|
+| 인수인계 시작점 | [documents/00-handover/README.md](../documents/00-handover/README.md) |
| 프로젝트 전체 계획 | [documents/01-getting-started/PLAN.md](../documents/01-getting-started/PLAN.md) |
-| Elevate.Web 아키텍처 | [documents/04-application/ELEVATE_WEB_ARCHITECTURE.md](../documents/04-application/ELEVATE_WEB_ARCHITECTURE.md) |
-| 컴포넌트 가이드 | [documents/04-application/ELEVATE_WEB_COMPONENTS.md](../documents/04-application/ELEVATE_WEB_COMPONENTS.md) |
-| API 계약 | [documents/04-application/DATA_MODEL_AND_API_CONTRACT.md](../documents/04-application/DATA_MODEL_AND_API_CONTRACT.md) |
-| 게시글 관리 가이드 | [documents/04-application/POSTS_GUIDE.md](../documents/04-application/POSTS_GUIDE.md) |
-| Microsoft Clarity 가이드 | [documents/04-application/CLARITY_INTEGRATION_GUIDE.md](../documents/04-application/CLARITY_INTEGRATION_GUIDE.md) |
+| Elevate.Web 아키텍처 | [documents/Elevate.Web/ARCHITECTURE.md](../documents/Elevate.Web/ARCHITECTURE.md) |
+| Elevate.Admin 아키텍처 | [documents/Elevate.Admin/ARCHITECTURE.md](../documents/Elevate.Admin/ARCHITECTURE.md) |
+| Elevate.Server 아키텍처 | [documents/Elevate.Server/ARCHITECTURE.md](../documents/Elevate.Server/ARCHITECTURE.md) |
| 배포 및 운영 | [documents/06-operations/DEPLOYMENT_AND_RUNBOOK.md](../documents/06-operations/DEPLOYMENT_AND_RUNBOOK.md) |
-
-
+| Microsoft Clarity 가이드 | [documents/04-application/CLARITY_INTEGRATION_GUIDE.md](../documents/04-application/CLARITY_INTEGRATION_GUIDE.md) |
diff --git a/Elevate.Web/src/components/posts/TableOfContents.jsx b/Elevate.Web/src/components/posts/TableOfContents.jsx
index ef18d8f..120ccf1 100644
--- a/Elevate.Web/src/components/posts/TableOfContents.jsx
+++ b/Elevate.Web/src/components/posts/TableOfContents.jsx
@@ -10,27 +10,7 @@
* `post-title` id: 게시글 제목을 목차 최상단에 별도 스타일로 추가하기 위한 가상 항목
*/
import { useEffect, useState, useRef } from 'react';
-
-/**
- * 게시글 본문(`article`) 내 h1~h3 요소에서 heading 목록을 추출한다.
- * @returns {{ id: string, text: string, level: number }[]}
- */
-const extractHeadingsFromDOM = () => {
- const headings = [];
- const headingElements = document.querySelectorAll('article h1, article h2, article h3');
-
- headingElements.forEach((element) => {
- if (element.id) {
- headings.push({
- id: element.id,
- text: element.textContent || element.innerText,
- level: parseInt(element.tagName[1], 10)
- });
- }
- });
-
- return headings;
-};
+import { getPostContentHeadings } from '../../utils/html';
/**
* 평탄한 heading 배열을 부모-자식 중첩 트리로 변환한다.
@@ -172,34 +152,13 @@ const TableOfContents = ({ contentMarkdown, postTitle, sticky = true }) => {
const observerRef = useRef(null);
// MutationObserver로 article DOM 변경을 감지하고 heading을 재추출한다.
- // dangerouslySetInnerHTML 반영 후 DOM이 업데이트될 때까지 150ms 딜레이를 둔다.
+ // heading id는 렌더 후 주입되므로 attribute 변경도 감지한다.
useEffect(() => {
- // article 요소 찾기
- const article = document.querySelector('article');
- if (!article) return;
+ const contentRoot = document.querySelector('article .post-content');
+ if (!contentRoot) return;
- // 초기 추출 (약간의 딜레이 포함)
- const initialTimer = setTimeout(() => {
- const flatHeadings = extractHeadingsFromDOM();
-
- // 게시글 제목을 첫 번째 항목으로 추가
- if (postTitle) {
- flatHeadings.unshift({
- id: 'post-title',
- text: postTitle,
- level: 1
- });
- }
-
- if (flatHeadings.length > 0) {
- const nested = buildNestedHeadings(flatHeadings);
- setHeadings(nested);
- }
- }, 150);
-
- // MutationObserver 설정 - article 내용 변경 감지
- const handleMutation = () => {
- const flatHeadings = extractHeadingsFromDOM();
+ const updateHeadings = () => {
+ const flatHeadings = getPostContentHeadings(contentRoot);
// 게시글 제목을 첫 번째 항목으로 추가
if (postTitle) {
@@ -213,18 +172,23 @@ const TableOfContents = ({ contentMarkdown, postTitle, sticky = true }) => {
if (flatHeadings.length > 0) {
const nested = buildNestedHeadings(flatHeadings);
setHeadings(nested);
+ } else {
+ setHeadings([]);
}
};
- const observer = new MutationObserver(handleMutation);
- observer.observe(article, {
+ updateHeadings();
+
+ const observer = new MutationObserver(updateHeadings);
+ observer.observe(contentRoot, {
+ attributes: true,
+ attributeFilter: ['id'],
childList: true,
subtree: true,
characterData: false
});
return () => {
- clearTimeout(initialTimer);
observer.disconnect();
};
}, [contentMarkdown, postTitle]);
@@ -241,7 +205,7 @@ const TableOfContents = ({ contentMarkdown, postTitle, sticky = true }) => {
}
// 마크다운 렌더링된 제목 요소들 수집
- const headingElements = document.querySelectorAll('article h1, article h2, article h3');
+ const headingElements = document.querySelectorAll('article .post-content h1, article .post-content h2, article .post-content h3');
if (headingElements.length === 0) return;
@@ -277,7 +241,7 @@ const TableOfContents = ({ contentMarkdown, postTitle, sticky = true }) => {
observerRef.current = observer;
const handleScroll = () => {
- const headingElements = document.querySelectorAll('article h1, article h2, article h3');
+ const headingElements = document.querySelectorAll('article .post-content h1, article .post-content h2, article .post-content h3');
if (headingElements.length === 0) return;
const scrollPosition = window.scrollY + window.innerHeight;
diff --git a/Elevate.Web/src/pages/PostDetail.jsx b/Elevate.Web/src/pages/PostDetail.jsx
index 764bc40..851941e 100644
--- a/Elevate.Web/src/pages/PostDetail.jsx
+++ b/Elevate.Web/src/pages/PostDetail.jsx
@@ -12,12 +12,12 @@
*/
import { useParams, Link, useNavigate } from 'react-router-dom';
import { Helmet } from 'react-helmet-async';
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import GlassDocLayout from '../components/layout/GlassDocLayout';
import TableOfContents from '../components/posts/TableOfContents';
import SeriesNavigator from '../components/posts/SeriesNavigator';
import { getPost, getLatestAgenthonPost } from '../api/posts';
-import { sanitizeHtml, injectHeadingIds, injectLinkHandlers, injectCollapsibleCodeBlocks, optimizeEmbeddedMedia } from '../utils/html';
+import { preparePostHtml, injectHeadingIds, injectLinkHandlers, injectCollapsibleCodeBlocks, optimizeEmbeddedMedia } from '../utils/html';
import { formatDateKo } from '../utils/url';
import { POST_DETAIL_VALID_CATEGORIES, CATEGORY_DISPLAY_NAMES, getCategoryListRoute } from '../constants/categories';
import { DEFAULT_OG_IMAGE, SITE_NAME, canonicalUrl } from '../constants/seo';
@@ -91,7 +91,11 @@ const PostDetail = ({ categoryProp, useLatest = false }) => {
if (!useLatest) setResolvedPostId(postIdParam ?? null);
}, [postIdParam, useLatest]);
- const postId = resolvedPostId;
+ const postId = resolvedPostId;
+ const preparedContentHtml = useMemo(
+ () => preparePostHtml(post?.contentMarkdown || ''),
+ [post?.contentMarkdown]
+ );
useEffect(() => {
if (!normalizedCategory || !postId) return;
@@ -122,7 +126,7 @@ const PostDetail = ({ categoryProp, useLatest = false }) => {
// HTML 콘텐츠 렌더링 후 heading ID 주입(TableOfContents용) + 링크 핸들러 주입(SPA 이동/외부 링크)
// + data-collapsible="true" 코드 블록에 접이식 토글 버튼 주입
- useEffect(() => {
+ useLayoutEffect(() => {
if (!contentRef.current || !post?.contentMarkdown) return;
optimizeEmbeddedMedia(contentRef.current);
injectHeadingIds(contentRef.current);
@@ -173,10 +177,10 @@ const PostDetail = ({ categoryProp, useLatest = false }) => {
{ label: postTitle },
];
- // 게시글 로드 완료 시 좌측 TOC를 표시한다.
- const leftAside = !loading && !loadingLatest && post
- ?
- : null;
+ // 게시글 로드 완료 시 좌측 TOC를 표시한다.
+ const leftAside = !loading && !loadingLatest && post
+ ?
+ : null;
// 시리즈가 있을 때만 우측 SeriesNavigator를 표시한다.
const rightAside = !loading && !loadingLatest && post && hasSeriesNavigator
@@ -316,11 +320,11 @@ const PostDetail = ({ categoryProp, useLatest = false }) => {
{/* Post Body */}
-
+
>
)}
diff --git a/Elevate.Web/src/utils/html.js b/Elevate.Web/src/utils/html.js
index e3963d6..3673161 100644
--- a/Elevate.Web/src/utils/html.js
+++ b/Elevate.Web/src/utils/html.js
@@ -24,6 +24,26 @@ export function sanitizeHtml(html) {
});
}
+/**
+ * 게시글 HTML을 렌더링 전에 소독하고 h1~h6 id를 주입한다.
+ *
+ * 렌더 후 DOM mutation에만 의존하면 React 재렌더나 effect 순서에 따라 기존 문서의
+ * heading id가 비어 있을 수 있다. 렌더 전에 HTML 문자열 자체를 안정화해 TOC와
+ * 실제 본문 앵커가 같은 id를 보게 한다.
+ *
+ * @param {string} html - API에서 받은 게시글 HTML 문자열
+ * @returns {string} sanitize + heading id 주입이 끝난 HTML 문자열
+ */
+export function preparePostHtml(html) {
+ const sanitized = sanitizeHtml(html);
+ if (!sanitized || typeof document === 'undefined') return sanitized;
+
+ const template = document.createElement('template');
+ template.innerHTML = sanitized;
+ injectHeadingIds(template.content);
+ return template.innerHTML;
+}
+
/**
* 컨테이너 내 모든 heading(h1~h6)에 id를 주입한다.
*
@@ -61,6 +81,27 @@ export function injectHeadingIds(containerEl) {
});
}
+/**
+ * 게시글 본문 컨테이너 내 h1~h3 요소에서 목차 항목을 추출한다.
+ *
+ * PostDetail이 렌더 후 heading id를 주입하므로, 목차 컴포넌트는 전역 article 대신
+ * 실제 본문 컨테이너를 기준으로 이 함수를 호출한다.
+ *
+ * @param {Element} containerEl - 게시글 본문 DOM 컨테이너
+ * @returns {{ id: string, text: string, level: number }[]}
+ */
+export function getPostContentHeadings(containerEl) {
+ if (!containerEl) return [];
+
+ return Array.from(containerEl.querySelectorAll('h1, h2, h3'))
+ .filter((element) => element.id)
+ .map((element) => ({
+ id: element.id,
+ text: element.textContent || element.innerText || '',
+ level: parseInt(element.tagName[1], 10),
+ }));
+}
+
/**
* 렌더링된 HTML 내 `` 링크 동작을 복원한다.
*
@@ -195,8 +236,13 @@ export function injectCollapsibleCodeBlocks(containerEl) {
// data-collapsible-injected 속성으로 중복 주입 방지 (idempotency)
containerEl.querySelectorAll('pre:not([data-collapsible-injected])').forEach((pre) => {
- const code = pre.querySelector('code');
- if (!code) return;
+ let code = pre.querySelector('code');
+ if (!code) {
+ code = document.createElement('code');
+ code.textContent = pre.textContent || '';
+ pre.textContent = '';
+ pre.appendChild(code);
+ }
const lines = code.textContent.replace(/\n$/, '').split('\n');
const isCollapsible = lines.length >= COLLAPSE_THRESHOLD && pre.getAttribute('data-collapsible') !== 'false';