- 3탭 구조: 프로필&경력, 프로젝트, 자기소개 - 비밀번호 인증 → 편집 모드 - 클립보드 복사, PDF 내보내기 (window.print) - 사이버펑크 테마 CSS, 모바일 반응형 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
134 lines
6.5 KiB
JavaScript
134 lines
6.5 KiB
JavaScript
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>
|
|
);
|
|
}
|