feat(portfolio): 포트폴리오 페이지 전체 구현

- 3탭 구조: 프로필&경력, 프로젝트, 자기소개
- 비밀번호 인증 → 편집 모드
- 클립보드 복사, PDF 내보내기 (window.print)
- 사이버펑크 테마 CSS, 모바일 반응형

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 14:37:25 +09:00
parent bebd55874c
commit a6dd2ef747
10 changed files with 1673 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
import { useCallback, useEffect, useState } from 'react';
import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import usePortfolioApi from './usePortfolioApi';
import PasswordModal from './PasswordModal';
import ProfileTab from './ProfileTab';
import ProjectTab from './ProjectTab';
import IntroTab from './IntroTab';
import ResumeView from './ResumeView';
import './Portfolio.css';
export default function Portfolio() {
const isMobile = useIsMobile();
const api = usePortfolioApi();
const [data, setData] = useState(null);
const [intros, setIntros] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editing, setEditing] = useState(false);
const [showPwModal, setShowPwModal] = useState(false);
const [showResume, setShowResume] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const d = await api.fetchPublic();
setData(d);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleEditToggle = () => {
if (editing) {
setEditing(false);
return;
}
if (api.token) {
setEditing(true);
} else {
setShowPwModal(true);
}
};
const handleAuth = async (pw) => {
const ok = await api.login(pw);
if (ok) {
setShowPwModal(false);
setEditing(true);
try {
const list = await api.fetchIntros();
setIntros(list);
} catch { /* 무시 */ }
}
};
const refresh = useCallback(async () => {
try {
const d = await api.fetchPublic();
setData(d);
if (api.token) {
const list = await api.fetchIntros();
setIntros(list);
}
} catch { /* 무시 */ }
}, [api.token]);
if (loading && !data) return <div className="pf-page"><p className="pf-loading">불러오는 ...</p></div>;
if (error && !data) return <div className="pf-page"><p className="pf-error">{error}</p></div>;
if (!data) return null;
if (showResume) {
return <ResumeView data={data} onClose={() => setShowResume(false)} />;
}
const tabs = [
{
key: 'profile',
label: '프로필',
content: <ProfileTab data={data} editing={editing} api={api} onRefresh={refresh} />,
},
{
key: 'projects',
label: '프로젝트',
content: <ProjectTab projects={data.projects} editing={editing} api={api} onRefresh={refresh} />,
},
{
key: 'intro',
label: '자기소개',
content: <IntroTab introductions={editing ? intros : (data.main_introduction ? [data.main_introduction] : [])} editing={editing} api={api} onRefresh={refresh} />,
},
];
return (
<div className="pf-page">
<div className="pf-toolbar">
<button className={`button ${editing ? 'primary' : 'ghost'}`} onClick={handleEditToggle}>
{editing ? '편집 완료' : '편집'}
</button>
<button className="button ghost" onClick={() => setShowResume(true)}>
PDF 내보내기
</button>
</div>
<SwipeableView tabs={tabs} />
<PasswordModal
open={showPwModal}
onAuth={handleAuth}
onClose={() => setShowPwModal(false)}
error={api.authError}
/>
</div>
);
}