Files
web-page/src/pages/blog/Blog.jsx
gahusb 0198fec43c refactor(responsive): Phase 3 코드 품질 개선
- Blog/BlogMarketing/Subscription/MusicStudio: 미사용 useIsMobile 제거
- Subscription: 미사용 Link import 제거
- Blog.css: 중복 display:block 제거
- BlogMarketing: dead prop onGenerate 제거
- Todo: 카드 버튼 터치 타겟 26→36px 확대

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 15:06:56 +09:00

669 lines
26 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getBlogPosts } from '../../data/blog';
import {
getBlogPostsApi,
createBlogPost,
updateBlogPost,
deleteBlogPost,
} from '../../api';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import './Blog.css';
// ── 마크다운 렌더러 ──────────────────────────────────────────────────────────
const renderInline = (text) => {
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
const pattern =
/(!\[[^\]]*\]\([^)]+\)|\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~|`[^`]+`)/g;
const segments = normalized.split('\n');
return segments.flatMap((segment, segmentIndex) => {
const parts = segment.split(pattern).filter(Boolean);
const rendered = parts.map((part, index) => {
if (part.startsWith('![')) {
const match = part.match(/!\[([^\]]*)\]\(([^)]+)\)/);
if (!match) return <span key={`${part}-${index}`}>{part}</span>;
const [, alt, src] = match;
return (
<img
key={`${part}-${index}`}
src={src}
alt={alt}
className="md-image"
loading="lazy"
/>
);
}
if (part.startsWith('[')) {
const match = part.match(/\[([^\]]+)\]\(([^)]+)\)/);
if (!match) return <span key={`${part}-${index}`}>{part}</span>;
const [, label, href] = match;
return (
<a
key={`${part}-${index}`}
href={href}
target="_blank"
rel="noreferrer"
>
{label}
</a>
);
}
if (part.startsWith('**')) {
return (
<strong key={`${part}-${index}`}>{part.replace(/\*\*/g, '')}</strong>
);
}
if (part.startsWith('*')) {
return <em key={`${part}-${index}`}>{part.replace(/\*/g, '')}</em>;
}
if (part.startsWith('~~')) {
return <del key={`${part}-${index}`}>{part.replace(/~~/g, '')}</del>;
}
if (part.startsWith('`')) {
return <code key={`${part}-${index}`}>{part.replace(/`/g, '')}</code>;
}
return <span key={`${part}-${index}`}>{part}</span>;
});
if (segmentIndex < segments.length - 1) {
rendered.push(<br key={`br-${segmentIndex}`} />);
}
return rendered;
});
};
const renderMarkdown = (body) => {
const lines = body.split(/\r?\n/);
const blocks = [];
let list = [];
let code = [];
let inCode = false;
const flushList = () => {
if (list.length) {
blocks.push({ type: 'list', items: list });
list = [];
}
};
const flushCode = () => {
if (code.length) {
blocks.push({ type: 'code', value: code.join('\n') });
code = [];
}
};
lines.forEach((line) => {
if (line.startsWith('```')) {
if (inCode) {
flushCode();
inCode = false;
} else {
flushList();
inCode = true;
}
return;
}
if (inCode) {
code.push(line);
return;
}
if (/^---$/.test(line.trim())) {
flushList();
blocks.push({ type: 'hr' });
return;
}
if (/^>\s+/.test(line)) {
flushList();
blocks.push({ type: 'quote', value: line.replace(/^>\s+/, '') });
return;
}
if (/^[-*]\s+/.test(line)) {
list.push(line.replace(/^[-*]\s+/, ''));
return;
}
flushList();
if (!line.trim()) return;
if (line.startsWith('###### ')) {
blocks.push({ type: 'h6', value: line.replace(/^######\s+/, '') });
return;
}
if (line.startsWith('##### ')) {
blocks.push({ type: 'h5', value: line.replace(/^#####\s+/, '') });
return;
}
if (line.startsWith('#### ')) {
blocks.push({ type: 'h4', value: line.replace(/^####\s+/, '') });
return;
}
if (line.startsWith('### ')) {
blocks.push({ type: 'h3', value: line.replace(/^###\s+/, '') });
return;
}
if (line.startsWith('## ')) {
blocks.push({ type: 'h2', value: line.replace(/^##\s+/, '') });
return;
}
if (line.startsWith('# ')) {
blocks.push({ type: 'h1', value: line.replace(/^#\s+/, '') });
return;
}
blocks.push({ type: 'p', value: line });
});
flushList();
flushCode();
return blocks.map((block, index) => {
if (block.type === 'h1') return <h1 key={index}>{block.value}</h1>;
if (block.type === 'h2') return <h2 key={index}>{block.value}</h2>;
if (block.type === 'h3') return <h3 key={index}>{block.value}</h3>;
if (block.type === 'h4') return <h4 key={index}>{block.value}</h4>;
if (block.type === 'h5') return <h5 key={index}>{block.value}</h5>;
if (block.type === 'h6') return <h6 key={index}>{block.value}</h6>;
if (block.type === 'list')
return (
<ul key={index}>
{block.items.map((item, itemIndex) => (
<li key={itemIndex}>{renderInline(item)}</li>
))}
</ul>
);
if (block.type === 'code')
return (
<pre key={index} className="md-code">
<code>{block.value}</code>
</pre>
);
if (block.type === 'quote')
return (
<blockquote key={index} className="md-quote">
{renderInline(block.value)}
</blockquote>
);
if (block.type === 'hr') return <hr key={index} className="md-hr" />;
return (
<p key={index} className="md-paragraph">
{renderInline(block.value)}
</p>
);
});
};
// ── 블로그 에디터 모달 ────────────────────────────────────────────────────────
const PRESET_TAGS = ['일상', '개발', '공부', '아이디어', '기타'];
const BlogEditor = ({ post, onSave, onClose }) => {
const [title, setTitle] = useState(post?.title || '');
const [tags, setTags] = useState(post?.tags || []);
const [body, setBody] = useState(post?.body || '');
const [showPreview, setShowPreview] = useState(false);
const [saving, setSaving] = useState(false);
const textareaRef = useRef(null);
// Tab 키로 들여쓰기 삽입
const handleKeyDown = (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const el = textareaRef.current;
const start = el.selectionStart;
const end = el.selectionEnd;
const next = body.substring(0, start) + ' ' + body.substring(end);
setBody(next);
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 2;
});
}
};
const toggleTag = (tag) => {
setTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
const handleSave = async () => {
if (!title.trim()) return;
setSaving(true);
try {
const today = new Date().toISOString().slice(0, 10);
const excerpt = body
.split(/\r?\n/)
.find((l) => l.trim() && !l.startsWith('#'))
?.trim()
.slice(0, 120) || '';
await onSave({
title: title.trim(),
tags,
body,
excerpt,
date: post?.date || today,
});
onClose();
} finally {
setSaving(false);
}
};
// ESC 키로 닫기
useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [onClose]);
return (
<div className="blog-editor-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="blog-editor">
<div className="blog-editor__header">
<h2 className="blog-editor__heading">
{post?.id ? '글 수정' : '새 글 쓰기'}
</h2>
<button type="button" className="blog-editor__close" onClick={onClose} aria-label="닫기">
</button>
</div>
<input
className="blog-editor__title-input"
type="text"
placeholder="제목을 입력하세요"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
<div className="blog-editor__tag-row">
{PRESET_TAGS.map((tag) => (
<button
key={tag}
type="button"
className={`blog-category-chip${tags.includes(tag) ? ' is-active' : ''}`}
onClick={() => toggleTag(tag)}
>
{tag}
</button>
))}
</div>
<div className="blog-editor__tab-bar">
<button
type="button"
className={`blog-editor__tab${!showPreview ? ' is-active' : ''}`}
onClick={() => setShowPreview(false)}
>
편집
</button>
<button
type="button"
className={`blog-editor__tab${showPreview ? ' is-active' : ''}`}
onClick={() => setShowPreview(true)}
>
미리보기
</button>
</div>
{showPreview ? (
<div className="blog-article__body blog-editor__preview">
{body
? renderMarkdown(body)
: <p style={{ color: 'var(--muted)' }}>본문을 입력하면 여기에 미리보기가 표시됩니다.</p>
}
</div>
) : (
<textarea
ref={textareaRef}
className="blog-editor__textarea"
placeholder="마크다운으로 글을 작성하세요...&#10;&#10;예시:&#10;# 제목&#10;## 소제목&#10;**굵게** *기울임* `코드`"
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleKeyDown}
spellCheck={false}
/>
)}
<div className="blog-editor__footer">
<button type="button" className="button" onClick={onClose}>
취소
</button>
<button
type="button"
className="button blog-editor__save-btn"
onClick={handleSave}
disabled={saving || !title.trim()}
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
);
};
// ── 메인 Blog 컴포넌트 ───────────────────────────────────────────────────────
const Blog = () => {
const staticPosts = useMemo(() => getBlogPosts(), []);
const [apiPosts, setApiPosts] = useState([]);
const [apiError, setApiError] = useState(false);
const [editorPost, setEditorPost] = useState(null); // null=닫힘, {}=새글, post=수정
const [isEditorOpen, setIsEditorOpen] = useState(false);
const fetchPosts = useCallback(() => {
return getBlogPostsApi()
.then((data) => {
const posts = Array.isArray(data) ? data : (data?.posts ?? []);
setApiPosts(posts.map((p) => ({ ...p, slug: `api-${p.id}` })));
})
.catch(() => setApiError(true));
}, []);
// API 글 불러오기
useEffect(() => {
fetchPosts();
}, [fetchPosts]);
// 정적 + API 글 병합 (API 글이 앞에 표시)
const allPosts = useMemo(() => {
const combined = [...apiPosts, ...staticPosts];
return combined.sort((a, b) => {
const aDate = Date.parse(a.date || '') || 0;
const bDate = Date.parse(b.date || '') || 0;
return bDate - aDate;
});
}, [apiPosts, staticPosts]);
const categoryNames = ['일상', '개발', '공부', '아이디어'];
const categorized = useMemo(() => {
const map = new Map(categoryNames.map((name) => [name, []]));
const misc = [];
allPosts.forEach((post) => {
const matched = categoryNames.find((name) => post.tags.includes(name));
if (matched) map.get(matched).push(post);
else misc.push(post);
});
return {
categories: categoryNames.map((name) => ({ name, items: map.get(name) })),
misc,
};
}, [allPosts]);
const [selectedCategory, setSelectedCategory] = useState('전체');
const [page, setPage] = useState(1);
const [showList, setShowList] = useState(false);
const pageSize = 10;
const filteredPosts = useMemo(() => {
if (selectedCategory === '전체') return allPosts;
if (selectedCategory === '기타') return categorized.misc;
return allPosts.filter((post) => post.tags.includes(selectedCategory));
}, [allPosts, categorized.misc, selectedCategory]);
const totalPages = Math.max(1, Math.ceil(filteredPosts.length / pageSize));
const pagedPosts = filteredPosts.slice((page - 1) * pageSize, page * pageSize);
const [activeSlug, setActiveSlug] = useState(pagedPosts[0]?.slug);
const activePost = pagedPosts.find((p) => p.slug === activeSlug) || pagedPosts[0];
useEffect(() => { if (page > totalPages) setPage(1); }, [page, totalPages]);
useEffect(() => {
if (!pagedPosts.find((p) => p.slug === activeSlug)) {
setActiveSlug(pagedPosts[0]?.slug);
}
}, [pagedPosts, activeSlug]);
useEffect(() => { setPage(1); }, [selectedCategory]);
// 에디터 저장 핸들러
const handleSave = useCallback(async (data) => {
if (editorPost?.id) {
// 수정
const updated = await updateBlogPost(editorPost.id, data);
setApiPosts((prev) =>
prev.map((p) =>
p.id === editorPost.id ? { ...p, ...updated, slug: `api-${updated.id ?? editorPost.id}` } : p
)
);
} else {
// 새 글
const created = await createBlogPost(data);
setApiPosts((prev) => [{ ...created, slug: `api-${created.id}` }, ...prev]);
setActiveSlug(`api-${created.id}`);
}
}, [editorPost]);
// 삭제 핸들러
const handleDelete = useCallback(async (post) => {
if (!window.confirm(`"${post.title}" 글을 삭제하시겠습니까?`)) return;
await deleteBlogPost(post.id);
setApiPosts((prev) => prev.filter((p) => p.id !== post.id));
if (activeSlug === post.slug) setActiveSlug(null);
}, [activeSlug]);
const openNewEditor = () => { setEditorPost({}); setIsEditorOpen(true); };
const openEditEditor = (post) => { setEditorPost(post); setIsEditorOpen(true); };
const closeEditor = useCallback(() => { setIsEditorOpen(false); setEditorPost(null); }, []);
return (
<PullToRefresh onRefresh={fetchPosts}>
<div className="blog">
<header className="blog-header">
<div>
<p className="blog-kicker">Journal</p>
<h1>개인 블로그</h1>
<p className="blog-sub">
글을 작성하고 태그를 달아 정리하세요.
</p>
</div>
<div className="blog-header__actions">
<div className="blog-status">
<p className="blog-status__title">이번 주의 기록</p>
<p className="blog-status__desc">
손에 닿는 생각을 즉시 적어두고, 나중에 다시 꺼내어 다듬습니다.
</p>
</div>
<button type="button" className="blog-new-btn" onClick={openNewEditor}>
+ 쓰기
</button>
</div>
</header>
<div className="blog-grid">
<button
type="button"
className="blog-toggle-list"
onClick={() => setShowList((prev) => !prev)}
aria-label="글 목록 토글"
>
</button>
<aside className={`blog-list ${showList ? 'is-visible' : ''}`}>
<div className="blog-category-filter">
{['전체', ...categoryNames, '기타'].map((name) => (
<button
key={name}
type="button"
className={`blog-category-chip${selectedCategory === name ? ' is-active' : ''}`}
onClick={() => setSelectedCategory(name)}
>
{name}
</button>
))}
</div>
{pagedPosts.map((post) => (
<div
key={post.slug}
className={`blog-list__item-wrap${post.slug === activeSlug ? ' is-active' : ''}`}
>
<button
type="button"
className="blog-list__item-btn"
onClick={() => {
setActiveSlug(post.slug);
setShowList(false);
}}
>
<p className="blog-list__title">{post.title}</p>
<p className="blog-list__excerpt">{post.excerpt}</p>
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
</button>
{post.id && (
<div className="blog-list__actions">
<button
type="button"
className="blog-list__action-btn"
title="수정"
onClick={() => openEditEditor(post)}
>
편집
</button>
<button
type="button"
className="blog-list__action-btn blog-list__action-btn--del"
title="삭제"
onClick={() => handleDelete(post)}
>
삭제
</button>
</div>
)}
</div>
))}
<div className="blog-pagination">
<button
type="button"
className="blog-page-btn"
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
disabled={page === 1}
>
이전
</button>
<span className="blog-page-indicator">{page} / {totalPages}</span>
<button
type="button"
className="blog-page-btn"
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
disabled={page === totalPages}
>
다음
</button>
</div>
</aside>
<article className="blog-article">
{activePost ? (
<>
<div className="blog-article__meta">
<span>{activePost.date || '작성일 미정'}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{activePost.tags.length > 0 && (
<span className="blog-tags">
{activePost.tags.map((tag) => (
<span key={tag} className="blog-tag">{tag}</span>
))}
</span>
)}
{activePost.id && (
<button
type="button"
className="blog-article__edit-btn"
onClick={() => openEditEditor(activePost)}
>
편집
</button>
)}
</div>
</div>
<div className="blog-article__body">
{renderMarkdown(activePost.body)}
</div>
</>
) : (
<p className="blog-empty">
{apiError
? '블로그 API에 연결할 수 없습니다. 백엔드 서버를 확인해 주세요.'
: '아직 작성된 글이 없습니다. 새 글 쓰기 버튼으로 첫 글을 작성해 보세요.'}
</p>
)}
</article>
</div>
<section className="blog-categories">
<div className="blog-categories__head">
<h2>카테고리</h2>
<p>태그 기준으로 글을 묶어 한눈에 확인할 있습니다.</p>
</div>
<div className="blog-categories__grid">
{categorized.categories.map((group) => (
<button
key={group.name}
type="button"
className="blog-category-card"
onClick={() => setSelectedCategory(group.name)}
>
<div className="blog-category-card__head">
<span>{group.name}</span>
<span className="blog-category-card__count">{group.items.length}</span>
</div>
<div className="blog-category-card__list">
{group.items.length ? (
group.items.slice(0, 3).map((post) => (
<span key={post.slug}>{post.title}</span>
))
) : (
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
)}
</div>
</button>
))}
<button
type="button"
className="blog-category-card"
onClick={() => setSelectedCategory('기타')}
>
<div className="blog-category-card__head">
<span>기타</span>
<span className="blog-category-card__count">{categorized.misc.length}</span>
</div>
<div className="blog-category-card__list">
{categorized.misc.length ? (
categorized.misc.slice(0, 3).map((post) => (
<span key={post.slug}>{post.title}</span>
))
) : (
<span className="blog-category-card__empty">아직 글이 없습니다.</span>
)}
</div>
</button>
</div>
</section>
{isEditorOpen && (
<BlogEditor
post={editorPost}
onSave={handleSave}
onClose={closeEditor}
/>
)}
<FAB onClick={openNewEditor} label="글 쓰기" />
</div>
</PullToRefresh>
);
};
export default Blog;