diff --git a/src/api.js b/src/api.js index f13eb97..80badc0 100644 --- a/src/api.js +++ b/src/api.js @@ -137,6 +137,22 @@ export function deletePortfolio(id) { return apiDelete(`/api/portfolio/${id}`); } +// ── 자산 스냅샷 API ────────────────────────────────────────────────────────── +// 장 마감 시점 총 자산을 기록하고, 기간별 추이를 조회합니다. + +// GET /api/portfolio/snapshot/history?days=N +// response: { history: [{ date: "2026-03-07", total_assets: 12345678 }, ...] } +export function getAssetHistory(days = 30) { + const qs = days ? `?days=${days}` : ''; + return apiGet(`/api/portfolio/snapshot/history${qs}`); +} + +// POST /api/portfolio/snapshot (body 없이 호출 — 서버가 현재 total_assets 계산해서 저장) +// 또는 body: { total_assets: number } 로 직접 지정 가능 +export function saveAssetSnapshot(total_assets) { + return apiPost('/api/portfolio/snapshot', total_assets != null ? { total_assets } : undefined); +} + // ── 예수금 API ─────────────────────────────────────────────────────────────── export function upsertCash(broker, cash) { @@ -204,3 +220,25 @@ export function deleteTodo(id) { export function clearTodos() { return apiDelete('/api/todos/done'); } + +// ── 블로그 API ──────────────────────────────────────────────────────────────── +// GET /api/blog/posts → { posts: [{id, title, tags, body, date, excerpt}] } +// POST /api/blog/posts → 새 글 생성 +// PUT /api/blog/posts/:id → 글 수정 +// DELETE /api/blog/posts/:id → 글 삭제 + +export function getBlogPostsApi() { + return apiGet('/api/blog/posts'); +} + +export function createBlogPost(data) { + return apiPost('/api/blog/posts', data); +} + +export function updateBlogPost(id, data) { + return apiPut(`/api/blog/posts/${id}`, data); +} + +export function deleteBlogPost(id) { + return apiDelete(`/api/blog/posts/${id}`); +} diff --git a/src/pages/blog/Blog.css b/src/pages/blog/Blog.css index 5e64e12..3346f08 100644 --- a/src/pages/blog/Blog.css +++ b/src/pages/blog/Blog.css @@ -10,6 +10,30 @@ align-items: center; } +.blog-header__actions { + display: flex; + flex-direction: column; + gap: 12px; +} + +.blog-new-btn { + align-self: flex-start; + border: 1px solid rgba(192, 132, 252, 0.45); + background: rgba(192, 132, 252, 0.1); + color: var(--accent-blog); + border-radius: 999px; + padding: 8px 18px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease; +} + +.blog-new-btn:hover { + background: rgba(192, 132, 252, 0.2); + border-color: rgba(192, 132, 252, 0.7); +} + .blog-kicker { text-transform: uppercase; letter-spacing: 0.3em; @@ -56,23 +80,27 @@ .blog-toggle-list { display: none; position: fixed; - top: 20px; - left: 20px; + /* 사이드바 토글 버튼(top-left) 과 겹치지 않도록 오른쪽 하단 배치 */ + bottom: 24px; + right: 24px; + top: auto; + left: auto; z-index: 1000; - width: 40px; - height: 40px; + width: 44px; + height: 44px; border-radius: 50%; - border: 1px solid var(--line); - background: rgba(10, 12, 20, 0.8); - color: var(--text); + border: 1px solid rgba(192, 132, 252, 0.45); + background: rgba(10, 12, 20, 0.88); + color: var(--accent-blog); font-size: 18px; cursor: pointer; - backdrop-filter: blur(10px); + backdrop-filter: blur(12px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); transition: transform 0.2s ease, opacity 0.2s ease; } .blog-toggle-list:hover { - transform: scale(1.1); + transform: scale(1.08); opacity: 0.9; } @@ -103,31 +131,84 @@ color: var(--accent-blog); } -.blog-list__item { +.blog-list__item-wrap { border: 1px solid var(--line); background: var(--surface); - padding: 16px; border-radius: var(--radius-md); - text-align: left; - cursor: pointer; - display: grid; - gap: 8px; transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; box-shadow: var(--shadow-inset); + overflow: hidden; } -.blog-list__item:hover { +.blog-list__item-wrap:hover { border-color: var(--line-strong); background: var(--surface-raised); box-shadow: var(--shadow-sm), var(--shadow-inset); } -.blog-list__item.is-active { +.blog-list__item-wrap.is-active { border-color: rgba(192, 132, 252, 0.5); box-shadow: 0 4px 20px rgba(192, 132, 252, 0.12), var(--shadow-inset); background: rgba(192, 132, 252, 0.05); } +.blog-list__item-btn { + width: 100%; + padding: 16px; + text-align: left; + cursor: pointer; + display: grid; + gap: 8px; + background: transparent; + border: none; + color: inherit; +} + +.blog-list__actions { + display: flex; + gap: 6px; + padding: 0 12px 10px; +} + +.blog-list__action-btn { + font-size: 11px; + padding: 3px 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.blog-list__action-btn:hover { + border-color: var(--accent-blog); + color: var(--accent-blog); +} + +.blog-list__action-btn--del:hover { + border-color: #f04452; + color: #f04452; +} + +.blog-article__edit-btn { + font-size: 11px; + padding: 4px 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + cursor: pointer; + text-transform: none; + letter-spacing: 0; + transition: border-color 0.15s, color 0.15s; +} + +.blog-article__edit-btn:hover { + border-color: var(--accent-blog); + color: var(--accent-blog); +} + .blog-pagination { display: flex; align-items: center; @@ -376,6 +457,12 @@ grid-template-columns: 1fr; } + .blog-header__actions { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + } + .blog-toggle-list { display: block; } @@ -427,7 +514,7 @@ gap: 10px; } - .blog-list__item { + .blog-list__item-btn { padding: 14px; } @@ -476,3 +563,207 @@ padding: 16px; } } + +/* ── 블로그 에디터 모달 ──────────────────────────────────────────────────── */ + +.blog-editor-overlay { + position: fixed; + inset: 0; + background: rgba(4, 6, 14, 0.75); + backdrop-filter: blur(6px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.blog-editor { + background: #0c0f1e; + border: 1px solid rgba(192, 132, 252, 0.25); + border-radius: var(--radius-xl, 20px); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(192, 132, 252, 0.06); + width: 100%; + max-width: 860px; + max-height: 92vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.blog-editor__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 24px 14px; + border-bottom: 1px solid var(--line); + flex-shrink: 0; +} + +.blog-editor__heading { + margin: 0; + font-size: 17px; + font-weight: 700; + color: var(--accent-blog); + letter-spacing: 0.04em; +} + +.blog-editor__close { + background: transparent; + border: none; + color: var(--muted); + font-size: 18px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + line-height: 1; + transition: color 0.15s; +} + +.blog-editor__close:hover { + color: var(--text-bright, #fff); +} + +.blog-editor__title-input { + margin: 14px 24px 0; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--line); + border-radius: var(--radius-md, 10px); + color: var(--text-bright, #f8f3ee); + font-size: 16px; + font-weight: 600; + outline: none; + transition: border-color 0.15s; + flex-shrink: 0; +} + +.blog-editor__title-input:focus { + border-color: rgba(192, 132, 252, 0.5); +} + +.blog-editor__title-input::placeholder { + color: var(--muted); +} + +.blog-editor__tag-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 24px 0; + flex-shrink: 0; +} + +.blog-editor__tab-bar { + display: flex; + gap: 4px; + padding: 12px 24px 0; + flex-shrink: 0; +} + +.blog-editor__tab { + padding: 5px 14px; + border-radius: 999px; + border: 1px solid var(--line); + background: transparent; + color: var(--muted); + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; +} + +.blog-editor__tab.is-active { + border-color: rgba(192, 132, 252, 0.55); + background: rgba(192, 132, 252, 0.12); + color: var(--accent-blog); +} + +.blog-editor__textarea { + flex: 1; + margin: 10px 24px 0; + padding: 14px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--line); + border-radius: var(--radius-md, 10px); + color: var(--text-bright, #f8f3ee); + font-size: 14px; + font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; + line-height: 1.75; + resize: none; + outline: none; + min-height: 320px; + transition: border-color 0.15s; +} + +.blog-editor__textarea:focus { + border-color: rgba(192, 132, 252, 0.4); +} + +.blog-editor__preview { + flex: 1; + margin: 10px 24px 0; + padding: 14px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--line); + border-radius: var(--radius-md, 10px); + overflow-y: auto; + min-height: 320px; +} + +.blog-editor__footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 14px 24px 18px; + border-top: 1px solid var(--line); + flex-shrink: 0; + margin-top: 12px; +} + +.blog-editor__save-btn { + border-color: rgba(192, 132, 252, 0.55) !important; + background: rgba(192, 132, 252, 0.15) !important; + color: var(--accent-blog) !important; +} + +.blog-editor__save-btn:hover:not(:disabled) { + background: rgba(192, 132, 252, 0.25) !important; +} + +.blog-editor__save-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +@media (max-width: 768px) { + .blog-editor-overlay { + align-items: flex-end; + padding: 0; + } + + .blog-editor { + max-width: 100%; + max-height: 95vh; + border-radius: var(--radius-xl, 20px) var(--radius-xl, 20px) 0 0; + } + + .blog-editor__title-input, + .blog-editor__tag-row, + .blog-editor__tab-bar, + .blog-editor__textarea, + .blog-editor__preview { + margin-left: 16px; + margin-right: 16px; + } + + .blog-editor__header, + .blog-editor__footer { + padding-left: 16px; + padding-right: 16px; + } + + .blog-new-btn { + align-self: stretch; + text-align: center; + } +} diff --git a/src/pages/blog/Blog.jsx b/src/pages/blog/Blog.jsx index 70b77f4..a23698d 100644 --- a/src/pages/blog/Blog.jsx +++ b/src/pages/blog/Blog.jsx @@ -1,7 +1,15 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getBlogPosts } from '../../data/blog'; +import { + getBlogPostsApi, + createBlogPost, + updateBlogPost, + deleteBlogPost, +} from '../../api'; import './Blog.css'; +// ── 마크다운 렌더러 ────────────────────────────────────────────────────────── + const renderInline = (text) => { const normalized = text.replace(//gi, '\n'); const pattern = @@ -122,9 +130,7 @@ const renderMarkdown = (body) => { flushList(); - if (!line.trim()) { - return; - } + if (!line.trim()) return; if (line.startsWith('###### ')) { blocks.push({ type: 'h6', value: line.replace(/^######\s+/, '') }); @@ -193,62 +199,255 @@ const renderMarkdown = (body) => { }); }; +// ── 블로그 에디터 모달 ──────────────────────────────────────────────────────── + +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 ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+

+ {post?.id ? '글 수정' : '새 글 쓰기'} +

+ +
+ + setTitle(e.target.value)} + autoFocus + /> + +
+ {PRESET_TAGS.map((tag) => ( + + ))} +
+ +
+ + +
+ + {showPreview ? ( +
+ {body + ? renderMarkdown(body) + :

본문을 입력하면 여기에 미리보기가 표시됩니다.

+ } +
+ ) : ( +