블로그 UI 수정
- 리스트와 작성글 3:7 비율로 변경 - 리스트 뷰 10개 넘어가면 pageState 추가 gitblog 블로그 일상 카테고리 글들 이전
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user