Skip to content
Merged
22 changes: 17 additions & 5 deletions Elevate.Admin/src/components/editor/AttachUploader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -139,7 +151,7 @@ export default function AttachUploader({ postId, draftSessionId, onUploadingChan
<input
ref={inputRef}
type="file"
accept=".docx,.xlsx,.pptx,.pdf,.csv,.zip,.xls,.doc"
accept={ATTACH_EXTENSIONS.join(',')}
onChange={handleFileChange}
className="hidden"
/>
Expand All @@ -154,7 +166,7 @@ export default function AttachUploader({ postId, draftSessionId, onUploadingChan
</button>
<span className="text-xs text-neutral-400">
{canUpload
? 'docx · xlsx · pptx · pdf · csv · zip · xls · doc (최대 50MB)'
? `${ATTACH_EXTENSIONS.map((ext) => ext.slice(1)).join(' · ')} (최대 50MB)`
: '첨부파일 업로드 준비 중입니다'}
</span>
</div>
Expand Down
25 changes: 16 additions & 9 deletions Elevate.Server/src/controllers/adminController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}

Expand Down
47 changes: 44 additions & 3 deletions Elevate.Server/tests/admin-attachments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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/);
Expand Down
14 changes: 6 additions & 8 deletions Elevate.Web/README.md
Original file line number Diff line number Diff line change
@@ -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`)는 테스트/보조 환경입니다.

## 빠른 시작

Expand All @@ -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) |
68 changes: 16 additions & 52 deletions Elevate.Web/src/components/posts/TableOfContents.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 배열을 부모-자식 중첩 트리로 변환한다.
Expand Down Expand Up @@ -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) {
Expand All @@ -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]);
Expand All @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
30 changes: 17 additions & 13 deletions Elevate.Web/src/pages/PostDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -173,10 +177,10 @@ const PostDetail = ({ categoryProp, useLatest = false }) => {
{ label: postTitle },
];

// 게시글 로드 완료 시 좌측 TOC를 표시한다.
const leftAside = !loading && !loadingLatest && post
? <TableOfContents contentMarkdown={post.contentMarkdown} postTitle={post.title} sticky={false} />
: null;
// 게시글 로드 완료 시 좌측 TOC를 표시한다.
const leftAside = !loading && !loadingLatest && post
? <TableOfContents contentMarkdown={preparedContentHtml} postTitle={post.title} sticky={false} />
: null;

// 시리즈가 있을 때만 우측 SeriesNavigator를 표시한다.
const rightAside = !loading && !loadingLatest && post && hasSeriesNavigator
Expand Down Expand Up @@ -316,11 +320,11 @@ const PostDetail = ({ categoryProp, useLatest = false }) => {

{/* Post Body */}
<article>
<div
ref={contentRef}
className="prose prose-slate max-w-none post-content"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(post.contentMarkdown || '') }}
/>
<div
ref={contentRef}
className="prose prose-slate max-w-none post-content"
dangerouslySetInnerHTML={{ __html: preparedContentHtml }}
/>
</article>
</>
)}
Expand Down
Loading