블로그 UI 수정

- 리스트와 작성글 3:7 비율로 변경
 - 리스트 뷰 10개 넘어가면 pageState 추가

gitblog 블로그 일상 카테고리 글들 이전
This commit is contained in:
2026-01-18 13:30:07 +09:00
parent 8462557ee3
commit 370cd4fca0
14 changed files with 839 additions and 21 deletions

View File

@@ -48,7 +48,7 @@
.blog-grid {
display: grid;
grid-template-columns: minmax(0, 0.45fr) minmax(0, 0.55fr);
grid-template-columns: minmax(0, 0.3fr) minmax(0, 0.7fr);
gap: 22px;
align-items: start;
}
@@ -58,6 +58,27 @@
gap: 12px;
}
.blog-category-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.blog-category-chip {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
}
.blog-category-chip.is-active {
border-color: rgba(247, 168, 165, 0.6);
background: rgba(247, 168, 165, 0.2);
}
.blog-list__item {
border: 1px solid var(--line);
background: var(--surface);
@@ -79,6 +100,34 @@
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
}
.blog-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding-top: 10px;
}
.blog-page-btn {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.05);
color: var(--text);
border-radius: 999px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
}
.blog-page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.blog-page-indicator {
color: var(--muted);
font-size: 12px;
}
.blog-list__title {
margin: 0;
font-weight: 600;
@@ -170,6 +219,31 @@
font-size: 0.85em;
}
.blog-article__body a {
color: #f7d4c9;
}
.md-image {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
margin: 12px 0;
}
.md-quote {
margin: 0 0 14px;
padding: 12px 16px;
border-left: 3px solid rgba(247, 168, 165, 0.6);
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
}
.md-hr {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.14);
margin: 18px 0;
}
.md-code {
padding: 14px;
border-radius: 12px;
@@ -183,6 +257,68 @@
color: var(--muted);
}
.blog-categories {
display: grid;
gap: 18px;
}
.blog-categories__head h2 {
margin: 0 0 6px;
font-size: 24px;
font-family: var(--font-display);
}
.blog-categories__head p {
margin: 0;
color: var(--muted);
}
.blog-categories__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.blog-category-card {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 18px;
padding: 16px;
text-align: left;
color: inherit;
cursor: pointer;
display: grid;
gap: 12px;
transition: border-color 0.2s ease;
}
.blog-category-card:hover {
border-color: rgba(255, 255, 255, 0.25);
}
.blog-category-card__head {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
}
.blog-category-card__count {
font-size: 12px;
color: var(--muted);
}
.blog-category-card__list {
display: grid;
gap: 6px;
color: var(--muted);
font-size: 13px;
}
.blog-category-card__empty {
color: var(--muted);
}
@media (max-width: 900px) {
.blog-header,
.blog-grid {

View File

@@ -1,24 +1,64 @@
import React, { useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { getBlogPosts } from '../../data/blog';
import './Blog.css';
const renderInline = (text) => {
const pattern = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g;
const parts = text.split(pattern).filter(Boolean);
const normalized = text.replace(/<br\s*\/?>/gi, '\n');
const pattern =
/(!\[[^\]]*\]\([^)]+\)|\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g;
const segments = normalized.split('\n');
return parts.map((part, index) => {
if (part.startsWith('**')) {
return (
<strong key={`${part}-${index}`}>{part.replace(/\*\*/g, '')}</strong>
);
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 <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}`} />);
}
if (part.startsWith('*')) {
return <em key={`${part}-${index}`}>{part.replace(/\*/g, '')}</em>;
}
if (part.startsWith('`')) {
return <code key={`${part}-${index}`}>{part.replace(/`/g, '')}</code>;
}
return <span key={`${part}-${index}`}>{part}</span>;
return rendered;
});
};
@@ -60,6 +100,18 @@ const renderMarkdown = (body) => {
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;
@@ -108,6 +160,13 @@ const renderMarkdown = (body) => {
<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)}
@@ -118,8 +177,59 @@ const renderMarkdown = (body) => {
const Blog = () => {
const posts = useMemo(() => getBlogPosts(), []);
const [activeSlug, setActiveSlug] = useState(posts[0]?.slug);
const activePost = posts.find((post) => post.slug === activeSlug) || posts[0];
const categoryNames = ['일상', '개발', '공부', '아이디어'];
const categorized = useMemo(() => {
const map = new Map(categoryNames.map((name) => [name, []]));
const misc = [];
posts.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,
};
}, [posts]);
const [selectedCategory, setSelectedCategory] = useState('전체');
const [page, setPage] = useState(1);
const pageSize = 10;
const filteredPosts = useMemo(() => {
if (selectedCategory === '전체') return posts;
if (selectedCategory === '기타') return categorized.misc;
return posts.filter((post) => post.tags.includes(selectedCategory));
}, [posts, 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((post) => post.slug === activeSlug) || pagedPosts[0];
useEffect(() => {
if (page > totalPages) {
setPage(1);
}
}, [page, totalPages]);
useEffect(() => {
if (!pagedPosts.find((post) => post.slug === activeSlug)) {
setActiveSlug(pagedPosts[0]?.slug);
}
}, [pagedPosts, activeSlug]);
useEffect(() => {
setPage(1);
}, [selectedCategory]);
return (
<div className="blog">
@@ -141,7 +251,21 @@ const Blog = () => {
<div className="blog-grid">
<aside className="blog-list">
{posts.map((post) => (
<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) => (
<button
key={post.slug}
type="button"
@@ -155,6 +279,29 @@ const Blog = () => {
<span className="blog-list__meta">{post.date || '작성일 미정'}</span>
</button>
))}
<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 ? (
@@ -183,6 +330,64 @@ const Blog = () => {
)}
</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>
</div>
);
};