feat(portfolio): 포트폴리오 페이지 전체 구현
- 3탭 구조: 프로필&경력, 프로젝트, 자기소개 - 비밀번호 인증 → 편집 모드 - 클립보드 복사, PDF 내보내기 (window.print) - 사이버펑크 테마 CSS, 모바일 반응형 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
133
src/pages/portfolio/ProjectTab.jsx
Normal file
133
src/pages/portfolio/ProjectTab.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'all', label: '전체' },
|
||||
{ key: 'company', label: '회사' },
|
||||
{ key: 'personal', label: '개인' },
|
||||
{ key: 'academy', label: '아카데미' },
|
||||
];
|
||||
|
||||
const emptyProject = {
|
||||
category: 'personal', title: '', description: '', tech_stack: [],
|
||||
role: '', start_date: '', end_date: '', url: '', image_url: '', sort_order: 0,
|
||||
};
|
||||
|
||||
export default function ProjectTab({ projects, editing, api, onRefresh }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [form, setForm] = useState(null);
|
||||
const [techInput, setTechInput] = useState('');
|
||||
|
||||
const filtered = filter === 'all' ? projects : projects.filter(p => p.category === filter);
|
||||
|
||||
const addTech = () => {
|
||||
const tag = techInput.trim();
|
||||
if (tag && !form.tech_stack.includes(tag)) {
|
||||
setForm(f => ({ ...f, tech_stack: [...f.tech_stack, tag] }));
|
||||
}
|
||||
setTechInput('');
|
||||
};
|
||||
|
||||
const removeTech = (tag) => {
|
||||
setForm(f => ({ ...f, tech_stack: f.tech_stack.filter(t => t !== tag) }));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (form.id) {
|
||||
await api.editProject(form.id, form);
|
||||
} else {
|
||||
await api.addProject(form);
|
||||
}
|
||||
setForm(null);
|
||||
setTechInput('');
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
await api.removeProject(id);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pf-project-tab">
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="pf-filter-bar">
|
||||
{CATEGORIES.map(c => (
|
||||
<button
|
||||
key={c.key}
|
||||
className={`pf-filter-btn${filter === c.key ? ' is-active' : ''}`}
|
||||
onClick={() => setFilter(c.key)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
{editing && <button className="button ghost" onClick={() => { setForm({...emptyProject}); setTechInput(''); }}>+ 추가</button>}
|
||||
</div>
|
||||
|
||||
{/* 추가/수정 폼 */}
|
||||
{form && (
|
||||
<div className="pf-edit-form">
|
||||
<label>구분
|
||||
<select value={form.category} onChange={(e) => setForm(f => ({...f, category: e.target.value}))}>
|
||||
{CATEGORIES.filter(c => c.key !== 'all').map(c => <option key={c.key} value={c.key}>{c.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>프로젝트명 <input value={form.title} onChange={(e) => setForm(f => ({...f, title: e.target.value}))} /></label>
|
||||
<label>설명 <textarea value={form.description} rows={3} onChange={(e) => setForm(f => ({...f, description: e.target.value}))} /></label>
|
||||
<label>담당 역할 <input value={form.role} onChange={(e) => setForm(f => ({...f, role: e.target.value}))} /></label>
|
||||
<div className="pf-edit-form__row">
|
||||
<label>시작 <input type="month" value={form.start_date} onChange={(e) => setForm(f => ({...f, start_date: e.target.value}))} /></label>
|
||||
<label>종료 <input type="month" value={form.end_date} onChange={(e) => setForm(f => ({...f, end_date: e.target.value}))} placeholder="현재" /></label>
|
||||
</div>
|
||||
<label>URL <input value={form.url} onChange={(e) => setForm(f => ({...f, url: e.target.value}))} /></label>
|
||||
<label>기술 스택
|
||||
<div className="pf-tech-input">
|
||||
<input
|
||||
value={techInput}
|
||||
onChange={(e) => setTechInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTech(); } }}
|
||||
placeholder="기술명 입력 후 Enter"
|
||||
/>
|
||||
<div className="pf-tech-tags">
|
||||
{form.tech_stack.map(t => (
|
||||
<span key={t} className="pf-tech-tag">{t} <button onClick={() => removeTech(t)}>×</button></span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<div className="pf-edit-form__actions">
|
||||
<button className="button ghost" onClick={() => { setForm(null); setTechInput(''); }}>취소</button>
|
||||
<button className="button primary" onClick={save}>저장</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 프로젝트 카드 그리드 */}
|
||||
<div className="pf-project-grid">
|
||||
{filtered.length === 0 && <p className="pf-empty">프로젝트가 없습니다.</p>}
|
||||
{filtered.map(p => (
|
||||
<div key={p.id} className="pf-project-card">
|
||||
<div className="pf-project-card__header">
|
||||
<span className={`pf-project-card__cat pf-project-card__cat--${p.category}`}>{CATEGORIES.find(c => c.key === p.category)?.label}</span>
|
||||
<span className="pf-project-card__period">{p.start_date} — {p.end_date || '현재'}</span>
|
||||
</div>
|
||||
<h4 className="pf-project-card__title">{p.title}</h4>
|
||||
{p.role && <p className="pf-project-card__role">{p.role}</p>}
|
||||
{p.description && <p className="pf-project-card__desc">{p.description}</p>}
|
||||
{p.tech_stack?.length > 0 && (
|
||||
<div className="pf-project-card__tags">
|
||||
{p.tech_stack.map(t => <span key={t} className="pf-tech-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
{p.url && <a className="pf-project-card__link" href={p.url} target="_blank" rel="noreferrer">링크 →</a>}
|
||||
{editing && (
|
||||
<div className="pf-project-card__actions">
|
||||
<button className="button ghost" onClick={() => { setForm({...p}); setTechInput(''); }}>수정</button>
|
||||
<button className="button ghost" onClick={() => remove(p.id)}>삭제</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user