- /api/profile/public에서 프로필·기술스택 동적 로드 - 서비스 미가동 시 하드코딩 폴백 유지 - "프로필 수정" → "포트폴리오 보기" Link로 교체 - 타임라인 섹션 제거 (포트폴리오 페이지에서 관리) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
363 lines
18 KiB
JavaScript
363 lines
18 KiB
JavaScript
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;
|