home 화면 todo list 보이게 추가
This commit is contained in:
83
src/data/heroConfig.js
Normal file
83
src/data/heroConfig.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 홈 히어로 카드 월별 테마 설정
|
||||
* 매달 month, theme, desc, nextUpdate 를 수정해 적용하세요.
|
||||
*/
|
||||
export const MONTHLY_THEMES = [
|
||||
{
|
||||
month: 1,
|
||||
theme: '새해 목표 설정',
|
||||
desc: '연초를 맞아 올해 개발·기록 목표를 구체적으로 정리하고 실행 계획을 세웁니다.',
|
||||
nextUpdate: '매주 일요일',
|
||||
},
|
||||
{
|
||||
month: 2,
|
||||
theme: '코드 품질 개선',
|
||||
desc: '리팩토링과 테스트 커버리지 향상에 집중합니다. 작은 개선도 꾸준히 쌓아갑니다.',
|
||||
nextUpdate: '매주 토요일',
|
||||
},
|
||||
{
|
||||
month: 3,
|
||||
theme: '웹 UI 고도화',
|
||||
desc: '대시보드 형태의 UI를 사이버펑크 스타일로 전면 개편하고, 새 기능을 추가합니다.',
|
||||
nextUpdate: '이번 주말',
|
||||
},
|
||||
{
|
||||
month: 4,
|
||||
theme: '백엔드 성능 최적화',
|
||||
desc: 'API 응답 속도와 데이터베이스 쿼리를 분석하고 병목을 개선하는 달입니다.',
|
||||
nextUpdate: '이번 주말',
|
||||
},
|
||||
{
|
||||
month: 5,
|
||||
theme: '인프라 자동화',
|
||||
desc: 'Docker/Kubernetes 파이프라인을 정비하고 배포 자동화를 강화합니다.',
|
||||
nextUpdate: '격주 일요일',
|
||||
},
|
||||
{
|
||||
month: 6,
|
||||
theme: '여름 사이드 프로젝트',
|
||||
desc: '새로운 기술 스택을 탐구하며 소규모 실험 프로젝트를 진행합니다.',
|
||||
nextUpdate: '매주 금요일',
|
||||
},
|
||||
{
|
||||
month: 7,
|
||||
theme: '기록과 문서화',
|
||||
desc: '그동안 미뤄뒀던 개발 노트와 블로그 글 작성에 집중합니다.',
|
||||
nextUpdate: '매주 화요일',
|
||||
},
|
||||
{
|
||||
month: 8,
|
||||
theme: '보안 점검',
|
||||
desc: '서비스 취약점을 점검하고 인증·인가 로직을 강화합니다.',
|
||||
nextUpdate: '격주 토요일',
|
||||
},
|
||||
{
|
||||
month: 9,
|
||||
theme: '모니터링 강화',
|
||||
desc: '로그 수집과 알림 파이프라인을 개선해 운영 가시성을 높입니다.',
|
||||
nextUpdate: '이번 주말',
|
||||
},
|
||||
{
|
||||
month: 10,
|
||||
theme: '오픈소스 기여',
|
||||
desc: '사용 중인 라이브러리에 이슈를 제보하거나 PR을 올려봅니다.',
|
||||
nextUpdate: '매주 목요일',
|
||||
},
|
||||
{
|
||||
month: 11,
|
||||
theme: '연말 회고 준비',
|
||||
desc: '올 한 해의 개발 성과를 정리하고 내년 로드맵 초안을 그립니다.',
|
||||
nextUpdate: '매주 일요일',
|
||||
},
|
||||
{
|
||||
month: 12,
|
||||
theme: '느린 기록, 깊은 회고',
|
||||
desc: '빠르게 달려온 한 해를 천천히 돌아보며 가장 의미 있었던 작업을 기록합니다.',
|
||||
nextUpdate: '크리스마스 주간',
|
||||
},
|
||||
];
|
||||
|
||||
export function getCurrentTheme() {
|
||||
const month = new Date().getMonth() + 1;
|
||||
return MONTHLY_THEMES.find((t) => t.month === month) ?? MONTHLY_THEMES[0];
|
||||
}
|
||||
@@ -367,91 +367,207 @@
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Dev Log ─────────────────────────────────────────────────────────── */
|
||||
/* ── TODO Board ──────────────────────────────────────────────────────── */
|
||||
|
||||
.home-dev-log {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
.home-todo-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.home-dev-log__empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 16px 0;
|
||||
.home-todo-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line-bright);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-bright);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.home-dev-log__item {
|
||||
.home-todo-nav:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.home-todo-nav--left { left: -16px; }
|
||||
.home-todo-nav--right { right: -16px; }
|
||||
|
||||
.home-todo-board {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line) transparent;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.home-todo-board::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.home-todo-board::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.home-todo-board::-webkit-scrollbar-thumb {
|
||||
background: var(--line);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.home-todo-col {
|
||||
flex: 1 0 260px;
|
||||
min-width: 0;
|
||||
max-width: 340px;
|
||||
scroll-snap-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--line);
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-card);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: start;
|
||||
gap: 14px;
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-dev-log__item:hover {
|
||||
border-color: rgba(52, 211, 153, 0.25);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.home-dev-log__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 6px rgba(52, 211, 153, 0.8);
|
||||
margin-top: 7px;
|
||||
.home-todo-col__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-dev-log__content {
|
||||
.home-todo-col__dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-todo-col__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--font-display);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.home-todo-col__count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.home-todo-col__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: calc(40vh);
|
||||
min-height: 60px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line) transparent;
|
||||
}
|
||||
|
||||
.home-todo-col__body::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.home-todo-col__body::-webkit-scrollbar-thumb {
|
||||
background: var(--line);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.home-todo-col__empty {
|
||||
margin: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.home-todo-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 11px 13px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.home-dev-log__title {
|
||||
.home-todo-card:hover {
|
||||
border-color: rgba(0, 212, 255, 0.18);
|
||||
background: rgba(0, 212, 255, 0.03);
|
||||
}
|
||||
|
||||
.home-todo-card__title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text-bright);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.home-dev-log__desc {
|
||||
.home-todo-card__desc {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.home-dev-log__date {
|
||||
font-size: 11px;
|
||||
color: rgba(52, 211, 153, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
padding-top: 4px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.home-dev-log__link {
|
||||
.home-todo-card__date {
|
||||
margin: 2px 0 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.home-todo-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.home-todo-footer__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: #34d399;
|
||||
text-decoration: none;
|
||||
padding: 8px 0;
|
||||
padding: 6px 0;
|
||||
transition: opacity 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.home-dev-log__link:hover {
|
||||
.home-todo-footer__link:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@@ -626,6 +742,19 @@
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.home-todo-col {
|
||||
flex: 0 0 80vw;
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
.home-todo-col__body {
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
.home-todo-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-hero h1 {
|
||||
font-size: clamp(22px, 6vw, 32px);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,42 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { navLinks } from '../../routes.jsx';
|
||||
import { getBlogPosts } from '../../data/blog';
|
||||
import { getTodos } from '../../api';
|
||||
import { getCurrentTheme } from '../../data/heroConfig';
|
||||
import myPhoto from '../../assets/myPhoto.jpg';
|
||||
import './Home.css';
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const TODO_COLUMNS = [
|
||||
{ id: 'todo', label: '계획', color: 'var(--neon-purple)' },
|
||||
{ id: 'in_progress', label: '진행 중', color: '#f59e0b' },
|
||||
{ id: 'done', label: '완료', color: '#34d399' },
|
||||
];
|
||||
|
||||
const Home = () => {
|
||||
const posts = getBlogPosts().slice(0, 3);
|
||||
const highlights = navLinks.filter((link) => link.id !== 'home');
|
||||
const [recentDev, setRecentDev] = useState([]);
|
||||
const theme = getCurrentTheme();
|
||||
|
||||
const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] });
|
||||
|
||||
useEffect(() => {
|
||||
getTodos()
|
||||
.then((todos) => {
|
||||
if (!Array.isArray(todos)) return;
|
||||
const now = Date.now();
|
||||
const filtered = todos
|
||||
.filter((t) => t.status === 'done' && t.updated_at && (now - new Date(t.updated_at).getTime()) <= SEVEN_DAYS_MS)
|
||||
.slice(0, 5);
|
||||
setRecentDev(filtered);
|
||||
.then((data) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
setTodosByStatus({
|
||||
todo: data.filter((t) => t.status === 'todo'),
|
||||
in_progress: data.filter((t) => t.status === 'in_progress'),
|
||||
done: data.filter((t) => t.status === 'done'),
|
||||
});
|
||||
})
|
||||
.catch(() => { /* 조용히 실패 */ });
|
||||
}, []);
|
||||
|
||||
const totalTasks = todosByStatus.todo.length + todosByStatus.in_progress.length + todosByStatus.done.length;
|
||||
const doneTasks = todosByStatus.done.length;
|
||||
const inProgress = todosByStatus.in_progress.length;
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<section className="home-hero">
|
||||
@@ -47,20 +58,21 @@ const Home = () => {
|
||||
<div className="home-hero__card">
|
||||
<p className="home-hero__card-eyebrow">이번 달 집중 테마</p>
|
||||
<div className="home-hero__card-body">
|
||||
<h2>느린 기록, 깊은 회고</h2>
|
||||
<p>
|
||||
빠르게 업데이트하는 대신, 한 번쯤 되돌아보며 기록하는 걸 목표로
|
||||
합니다. 글은 매주 한 편씩 추가될 예정이에요.
|
||||
</p>
|
||||
<h2>{theme.theme}</h2>
|
||||
<p>{theme.desc}</p>
|
||||
</div>
|
||||
<div className="home-hero__stats">
|
||||
<div className="home-hero__stat">
|
||||
<p className="stat-label">게시 글</p>
|
||||
<p className="stat-value">{posts.length}<span className="stat-unit">편</span></p>
|
||||
<p className="stat-label">전체 태스크</p>
|
||||
<p className="stat-value">
|
||||
{totalTasks}<span className="stat-unit">개</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-hero__stat">
|
||||
<p className="stat-label">다음 업데이트</p>
|
||||
<p className="stat-value stat-value--sm">이번 주말</p>
|
||||
<p className="stat-label">진행 중 / 완료</p>
|
||||
<p className="stat-value stat-value--sm">
|
||||
{inProgress}<span className="stat-unit"> / </span>{doneTasks}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,34 +126,13 @@ const Home = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── TODO 보드 ──────────────────────────────────────────── */}
|
||||
<section className="home-section">
|
||||
<div className="home-section__header">
|
||||
<h2>최근 개발</h2>
|
||||
<p>최근 7일 내 완료된 태스크를 보여줍니다.</p>
|
||||
</div>
|
||||
<div className="home-dev-log">
|
||||
{recentDev.length === 0 ? (
|
||||
<p className="home-dev-log__empty">완료된 태스크가 없습니다.</p>
|
||||
) : (
|
||||
recentDev.map((todo) => (
|
||||
<div key={todo.id} className="home-dev-log__item">
|
||||
<span className="home-dev-log__dot" />
|
||||
<div className="home-dev-log__content">
|
||||
<p className="home-dev-log__title">{todo.title}</p>
|
||||
{todo.description && (
|
||||
<p className="home-dev-log__desc">{todo.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="home-dev-log__date">
|
||||
{new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Link to="/todo" className="home-dev-log__link">
|
||||
Todo 보드 열기 →
|
||||
</Link>
|
||||
<h2>TODO</h2>
|
||||
<p>계획 · 진행 중 · 완료 태스크를 한눈에 확인합니다.</p>
|
||||
</div>
|
||||
<TodoBoard todosByStatus={todosByStatus} />
|
||||
</section>
|
||||
|
||||
<section className="home-section">
|
||||
@@ -205,4 +196,99 @@ const Home = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/* ── TodoBoard ──────────────────────────────────────────────────────── */
|
||||
|
||||
const TodoBoard = ({ todosByStatus }) => {
|
||||
const boardRef = useRef(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const checkScroll = () => {
|
||||
const el = boardRef.current;
|
||||
if (!el) return;
|
||||
setCanScrollLeft(el.scrollLeft > 4);
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkScroll();
|
||||
const el = boardRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener('scroll', checkScroll, { passive: true });
|
||||
const ro = new ResizeObserver(checkScroll);
|
||||
ro.observe(el);
|
||||
return () => { el.removeEventListener('scroll', checkScroll); ro.disconnect(); };
|
||||
}, [todosByStatus]);
|
||||
|
||||
const scroll = (dir) => {
|
||||
const el = boardRef.current;
|
||||
if (!el) return;
|
||||
el.scrollBy({ left: dir * 280, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const isEmpty = TODO_COLUMNS.every((col) => todosByStatus[col.id].length === 0);
|
||||
|
||||
return (
|
||||
<div className="home-todo-wrapper">
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
className="home-todo-nav home-todo-nav--left"
|
||||
onClick={() => scroll(-1)}
|
||||
aria-label="왼쪽으로"
|
||||
>‹</button>
|
||||
)}
|
||||
{canScrollRight && (
|
||||
<button
|
||||
className="home-todo-nav home-todo-nav--right"
|
||||
onClick={() => scroll(1)}
|
||||
aria-label="오른쪽으로"
|
||||
>›</button>
|
||||
)}
|
||||
|
||||
<div className="home-todo-board" ref={boardRef}>
|
||||
{TODO_COLUMNS.map((col) => {
|
||||
const items = todosByStatus[col.id] ?? [];
|
||||
return (
|
||||
<div key={col.id} className="home-todo-col">
|
||||
<div className="home-todo-col__head">
|
||||
<span
|
||||
className="home-todo-col__dot"
|
||||
style={{ background: col.color, boxShadow: `0 0 6px ${col.color}` }}
|
||||
/>
|
||||
<span className="home-todo-col__label">{col.label}</span>
|
||||
<span className="home-todo-col__count">{items.length}</span>
|
||||
</div>
|
||||
<div className="home-todo-col__body">
|
||||
{items.length === 0 ? (
|
||||
<p className="home-todo-col__empty">태스크가 없습니다.</p>
|
||||
) : (
|
||||
items.map((todo) => (
|
||||
<div key={todo.id} className="home-todo-card">
|
||||
<p className="home-todo-card__title">{todo.title}</p>
|
||||
{todo.description && (
|
||||
<p className="home-todo-card__desc">{todo.description}</p>
|
||||
)}
|
||||
<p className="home-todo-card__date">
|
||||
{todo.updated_at
|
||||
? new Date(todo.updated_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="home-todo-footer">
|
||||
<Link to="/todo" className="home-todo-footer__link">
|
||||
Todo 보드 열기 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
Reference in New Issue
Block a user