feat(portfolio): 포트폴리오 페이지 전체 구현
- 3탭 구조: 프로필&경력, 프로젝트, 자기소개 - 비밀번호 인증 → 편집 모드 - 클립보드 복사, PDF 내보내기 (window.print) - 사이버펑크 테마 CSS, 모바일 반응형 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
202
src/pages/portfolio/ProfileTab.jsx
Normal file
202
src/pages/portfolio/ProfileTab.jsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const CAREER_CATEGORIES = { company: '회사', education: '교육', etc: '기타' };
|
||||
const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', infra: '인프라', tool: '도구' };
|
||||
|
||||
const emptyCareer = { category: 'company', organization: '', role: '', description: '', start_date: '', end_date: '', sort_order: 0 };
|
||||
const emptySkill = { category: 'language', name: '', level: 3, sort_order: 0 };
|
||||
|
||||
export default function ProfileTab({ data, editing, api, onRefresh }) {
|
||||
const { profile, careers, skills } = data;
|
||||
const [editingProfile, setEditingProfile] = useState(null);
|
||||
const [careerForm, setCareerForm] = useState(null);
|
||||
const [skillForm, setSkillForm] = useState(null);
|
||||
|
||||
// ── Profile 편집 ──
|
||||
const startEditProfile = () => setEditingProfile({ ...profile });
|
||||
const saveProfileEdit = async () => {
|
||||
await api.saveProfile(editingProfile);
|
||||
setEditingProfile(null);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
// ── Career CRUD ──
|
||||
const saveCareer = async () => {
|
||||
if (careerForm.id) {
|
||||
await api.editCareer(careerForm.id, careerForm);
|
||||
} else {
|
||||
await api.addCareer(careerForm);
|
||||
}
|
||||
setCareerForm(null);
|
||||
onRefresh();
|
||||
};
|
||||
const deleteCareer = async (id) => {
|
||||
await api.removeCareer(id);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
// ── Skill CRUD ──
|
||||
const saveSkill = async () => {
|
||||
if (skillForm.id) {
|
||||
await api.editSkill(skillForm.id, skillForm);
|
||||
} else {
|
||||
await api.addSkill(skillForm);
|
||||
}
|
||||
setSkillForm(null);
|
||||
onRefresh();
|
||||
};
|
||||
const deleteSkill = async (id) => {
|
||||
await api.removeSkill(id);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const grouped = (items, catMap) => {
|
||||
const groups = {};
|
||||
for (const key of Object.keys(catMap)) groups[key] = [];
|
||||
for (const item of items) {
|
||||
const cat = item.category || Object.keys(catMap)[0];
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(item);
|
||||
}
|
||||
return groups;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pf-profile-tab">
|
||||
{/* ── 프로필 카드 ── */}
|
||||
<div className="pf-profile-card">
|
||||
{editingProfile ? (
|
||||
<div className="pf-edit-form">
|
||||
<label>이름 <input value={editingProfile.name} onChange={(e) => setEditingProfile(p => ({...p, name: e.target.value}))} /></label>
|
||||
<label>이름(영문) <input value={editingProfile.name_en} onChange={(e) => setEditingProfile(p => ({...p, name_en: e.target.value}))} /></label>
|
||||
<label>직함 <input value={editingProfile.role} onChange={(e) => setEditingProfile(p => ({...p, role: e.target.value}))} /></label>
|
||||
<label>직함(영문) <input value={editingProfile.role_en} onChange={(e) => setEditingProfile(p => ({...p, role_en: e.target.value}))} /></label>
|
||||
<label>이메일 <input value={editingProfile.email} onChange={(e) => setEditingProfile(p => ({...p, email: e.target.value}))} /></label>
|
||||
<label>전화번호 <input value={editingProfile.phone} onChange={(e) => setEditingProfile(p => ({...p, phone: e.target.value}))} /></label>
|
||||
<label>GitHub <input value={editingProfile.github_url} onChange={(e) => setEditingProfile(p => ({...p, github_url: e.target.value}))} /></label>
|
||||
<label>블로그 <input value={editingProfile.blog_url} onChange={(e) => setEditingProfile(p => ({...p, blog_url: e.target.value}))} /></label>
|
||||
<label>사진 URL <input value={editingProfile.photo_url} onChange={(e) => setEditingProfile(p => ({...p, photo_url: e.target.value}))} /></label>
|
||||
<label>소개 <textarea value={editingProfile.bio} rows={3} onChange={(e) => setEditingProfile(p => ({...p, bio: e.target.value}))} /></label>
|
||||
<div className="pf-edit-form__actions">
|
||||
<button className="button ghost" onClick={() => setEditingProfile(null)}>취소</button>
|
||||
<button className="button primary" onClick={saveProfileEdit}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pf-profile-card__header">
|
||||
{profile.photo_url && <img className="pf-profile-card__photo" src={profile.photo_url} alt="" />}
|
||||
<div>
|
||||
<h2 className="pf-profile-card__name">{profile.name || '이름 미설정'}</h2>
|
||||
{profile.name_en && <p className="pf-profile-card__name-en">{profile.name_en}</p>}
|
||||
<p className="pf-profile-card__role">{profile.role || profile.role_en}</p>
|
||||
</div>
|
||||
</div>
|
||||
{profile.bio && <p className="pf-profile-card__bio">{profile.bio}</p>}
|
||||
<div className="pf-profile-card__links">
|
||||
{profile.email && <a href={`mailto:${profile.email}`}>{profile.email}</a>}
|
||||
{profile.github_url && <a href={profile.github_url} target="_blank" rel="noreferrer">GitHub</a>}
|
||||
{profile.blog_url && <a href={profile.blog_url} target="_blank" rel="noreferrer">Blog</a>}
|
||||
</div>
|
||||
{editing && <button className="button ghost pf-edit-btn" onClick={startEditProfile}>프로필 수정</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 경력 타임라인 ── */}
|
||||
<div className="pf-section">
|
||||
<div className="pf-section__header">
|
||||
<h3>경력</h3>
|
||||
{editing && <button className="button ghost" onClick={() => setCareerForm({...emptyCareer})}>+ 추가</button>}
|
||||
</div>
|
||||
{careerForm && (
|
||||
<div className="pf-edit-form">
|
||||
<label>구분
|
||||
<select value={careerForm.category} onChange={(e) => setCareerForm(f => ({...f, category: e.target.value}))}>
|
||||
{Object.entries(CAREER_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>기관명 <input value={careerForm.organization} onChange={(e) => setCareerForm(f => ({...f, organization: e.target.value}))} /></label>
|
||||
<label>직함 <input value={careerForm.role} onChange={(e) => setCareerForm(f => ({...f, role: e.target.value}))} /></label>
|
||||
<label>설명 <textarea value={careerForm.description} rows={2} onChange={(e) => setCareerForm(f => ({...f, description: e.target.value}))} /></label>
|
||||
<div className="pf-edit-form__row">
|
||||
<label>시작 <input type="month" value={careerForm.start_date} onChange={(e) => setCareerForm(f => ({...f, start_date: e.target.value}))} /></label>
|
||||
<label>종료 <input type="month" value={careerForm.end_date} onChange={(e) => setCareerForm(f => ({...f, end_date: e.target.value}))} placeholder="현재" /></label>
|
||||
</div>
|
||||
<div className="pf-edit-form__actions">
|
||||
<button className="button ghost" onClick={() => setCareerForm(null)}>취소</button>
|
||||
<button className="button primary" onClick={saveCareer}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(grouped(careers, CAREER_CATEGORIES)).map(([cat, items]) =>
|
||||
items.length > 0 && (
|
||||
<div key={cat} className="pf-career-group">
|
||||
<h4 className="pf-career-group__title">{CAREER_CATEGORIES[cat]}</h4>
|
||||
{items.map((c) => (
|
||||
<div key={c.id} className="pf-career-item">
|
||||
<span className="pf-career-item__period">{c.start_date} — {c.end_date || '현재'}</span>
|
||||
<strong className="pf-career-item__role">{c.role}</strong>
|
||||
<span className="pf-career-item__org">{c.organization}</span>
|
||||
{c.description && <p className="pf-career-item__desc">{c.description}</p>}
|
||||
{editing && (
|
||||
<div className="pf-career-item__actions">
|
||||
<button className="button ghost" onClick={() => setCareerForm({...c})}>수정</button>
|
||||
<button className="button ghost" onClick={() => deleteCareer(c.id)}>삭제</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 기술 스택 ── */}
|
||||
<div className="pf-section">
|
||||
<div className="pf-section__header">
|
||||
<h3>기술 스택</h3>
|
||||
{editing && <button className="button ghost" onClick={() => setSkillForm({...emptySkill})}>+ 추가</button>}
|
||||
</div>
|
||||
{skillForm && (
|
||||
<div className="pf-edit-form">
|
||||
<label>구분
|
||||
<select value={skillForm.category} onChange={(e) => setSkillForm(f => ({...f, category: e.target.value}))}>
|
||||
{Object.entries(SKILL_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>기술명 <input value={skillForm.name} onChange={(e) => setSkillForm(f => ({...f, name: e.target.value}))} /></label>
|
||||
<label>숙련도 (1~5)
|
||||
<input type="range" min={1} max={5} value={skillForm.level} onChange={(e) => setSkillForm(f => ({...f, level: +e.target.value}))} />
|
||||
<span>{skillForm.level}</span>
|
||||
</label>
|
||||
<div className="pf-edit-form__actions">
|
||||
<button className="button ghost" onClick={() => setSkillForm(null)}>취소</button>
|
||||
<button className="button primary" onClick={saveSkill}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Object.entries(grouped(skills, SKILL_CATEGORIES)).map(([cat, items]) =>
|
||||
items.length > 0 && (
|
||||
<div key={cat} className="pf-skill-group">
|
||||
<h4 className="pf-skill-group__title">{SKILL_CATEGORIES[cat]}</h4>
|
||||
<div className="pf-skill-group__tags">
|
||||
{items.map((s) => (
|
||||
<span key={s.id} className="pf-skill-tag" data-level={s.level}>
|
||||
{s.name}
|
||||
{editing && (
|
||||
<span className="pf-skill-tag__actions">
|
||||
<button onClick={() => setSkillForm({...s})}>✎</button>
|
||||
<button onClick={() => deleteSkill(s.id)}>×</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user