- 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>
669 lines
26 KiB
JavaScript
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="마크다운으로 글을 작성하세요... 예시: # 제목 ## 소제목 **굵게** *기울임* `코드`"
|
|
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;
|