Files
web-page/src/pages/home/Home.jsx
gahusb a56923a6b3 refactor(home): Profile 섹션 portfolio API 연동
- /api/profile/public에서 프로필·기술스택 동적 로드
- 서비스 미가동 시 하드코딩 폴백 유지
- "프로필 수정" → "포트폴리오 보기" Link로 교체
- 타임라인 섹션 제거 (포트폴리오 페이지에서 관리)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:38:18 +09:00

363 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, 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 { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import PullToRefresh from '../../components/PullToRefresh';
import './Home.css';
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 theme = getCurrentTheme();
const isMobile = useIsMobile();
const [todosByStatus, setTodosByStatus] = useState({ todo: [], in_progress: [], done: [] });
const [portfolio, setPortfolio] = useState(null);
useEffect(() => {
fetch('/api/profile/public')
.then(r => r.ok ? r.json() : null)
.catch(() => null)
.then(d => setPortfolio(d));
}, []);
const loadTodos = useCallback(async () => {
const data = await getTodos();
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'),
});
}, []);
useEffect(() => {
loadTodos().catch(() => { /* 조용히 실패 */ });
}, [loadTodos]);
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">
<div className="home-hero__text">
<p className="home-hero__kicker">Personal Archive</p>
<h1>기록을 모으고,<br />이야기를 이어붙이는 작은 .</h1>
<p className="home-hero__lead">
개발, 여행 스냅, 그리고 생각을 모아두는 공간입니다.
</p>
<div className="home-hero__actions">
<Link className="button primary" to="/blog">
블로그 둘러보기
</Link>
<Link className="button ghost" to="/travel">
여행 갤러리 열기
</Link>
</div>
</div>
<div className="home-hero__card">
<p className="home-hero__card-eyebrow">이번 집중 테마</p>
<div className="home-hero__card-body">
<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">
{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">
{inProgress}<span className="stat-unit"> / </span>{doneTasks}
</p>
</div>
</div>
</div>
</section>
<section className="home-section">
<div className="home-section__header">
<h2>공간 둘러보기</h2>
<p>확장 가능한 구조로 구성해 이후에도 쉽게 페이지를 추가할 있습니다.</p>
</div>
<div className="home-grid">
{highlights.map((item) => (
<Link
key={item.id}
to={item.path}
className="home-card"
style={{ '--card-accent': item.accent }}
>
<div
className="home-card__icon"
style={{ color: item.accent }}
>
{item.icon}
</div>
<div className="home-card__body">
<p className="home-card__title">{item.label}</p>
<p className="home-card__desc">{item.description}</p>
</div>
<span className="home-card__arrow"></span>
</Link>
))}
</div>
</section>
<section className="home-section">
<div className="home-section__header">
<h2>최근 블로그</h2>
<p>마크다운 파일을 추가하면 자동으로 목록에 반영됩니다.</p>
</div>
<div className="home-posts">
{posts.map((post) => (
<Link key={post.slug} to="/blog" className="home-post">
<div className="home-post__dot" />
<div className="home-post__content">
<p className="home-post__title">{post.title}</p>
<p className="home-post__excerpt">{post.excerpt}</p>
</div>
<span className="home-post__meta">{post.date || '작성일 미정'}</span>
</Link>
))}
</div>
</section>
{/* ── TODO 보드 ──────────────────────────────────────────── */}
<section className="home-section">
<div className="home-section__header">
<h2>TODO</h2>
<p>계획 · 진행 · 완료 태스크를 한눈에 확인합니다.</p>
</div>
<PullToRefresh onRefresh={loadTodos}>
{isMobile ? (
<SwipeableView
tabs={[
{
key: 'todo',
label: 'TODO',
content: (
<div className="home-todo-col__body">
{(todosByStatus.todo || []).length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (todosByStatus.todo || []).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>
),
},
{
key: 'in_progress',
label: '진행중',
content: (
<div className="home-todo-col__body">
{(todosByStatus.in_progress || []).length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (todosByStatus.in_progress || []).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>
),
},
{
key: 'done',
label: '완료',
content: (
<div className="home-todo-col__body">
{(todosByStatus.done || []).length === 0 ? (
<p className="home-todo-col__empty">태스크가 없습니다.</p>
) : (todosByStatus.done || []).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>
),
},
]}
/>
) : (
<TodoBoard todosByStatus={todosByStatus} />
)}
</PullToRefresh>
</section>
<section className="home-section">
<div className="home-section__header">
<h2>Profile</h2>
<p>페이지 주인 소개 영역입니다.</p>
</div>
<div className="home-profile">
<div className="home-profile__card">
<div className="home-profile__identity">
<img
className="home-profile__avatar"
src={portfolio?.profile?.photo_url || myPhoto}
alt="Profile"
/>
<div>
<p className="home-profile__role">{portfolio?.profile?.role || 'Server Developer'}</p>
<p className="home-profile__name">{portfolio?.profile?.name || '박 재 오'}</p>
</div>
</div>
<p className="home-profile__bio">
{portfolio?.profile?.bio || '주변 동료와 함께 소통하며 성장하는걸 좋아합니다.'}
</p>
<div className="home-profile__tags">
{(portfolio?.skills || []).slice(0, 8).map((s) => (
<span key={s.id || s.name}>{s.name}</span>
))}
{!portfolio && ['C++', 'Git', 'AWS', 'Jira', 'MySQL', 'Docker', 'Kubernetes', 'Linux'].map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<div className="home-profile__actions">
<Link className="button ghost" to="/portfolio">
포트폴리오 보기
</Link>
<a className="button primary" href={`mailto:${portfolio?.profile?.email || 'bgg8988@gmail.com'}`}>
연락하기
</a>
</div>
</div>
</div>
</section>
</div>
);
};
/* ── 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;