LogoLoop 무한 캐러셀이 항목 수가 적은 카테고리에서 반복돼 시각적으로 산만한 문제. 카테고리별로 단순 flex-wrap 줄로 정적 표시. SkillLogoNode와 fallback 로직은 유지. LogoLoop 컴포넌트 자체는 다른 페이지에서 재사용 여지를 위해 보존. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
252 lines
13 KiB
JavaScript
252 lines
13 KiB
JavaScript
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 };
|
|
|
|
const SKILL_LOGO_SLUGS = {
|
|
Python: 'python',
|
|
JavaScript: 'javascript',
|
|
SQL: 'mysql',
|
|
'HTML/CSS': 'html5',
|
|
FastAPI: 'fastapi',
|
|
React: 'react',
|
|
Vite: 'vite',
|
|
Docker: 'docker',
|
|
'Synology NAS': 'synology',
|
|
Nginx: 'nginx',
|
|
Gitea: 'gitea',
|
|
SQLite: 'sqlite',
|
|
Linux: 'linux',
|
|
Git: 'git',
|
|
'Claude API': 'anthropic',
|
|
Ollama: 'ollama',
|
|
'Suno API': 'suno',
|
|
};
|
|
|
|
function SkillLogoNode({ name, slug }) {
|
|
const [error, setError] = useState(false);
|
|
if (!slug || error) {
|
|
return <span className="pf-skill-fallback">{name}</span>;
|
|
}
|
|
return (
|
|
<img
|
|
className="pf-skill-logo"
|
|
src={`https://cdn.simpleicons.org/${slug}`}
|
|
alt={name}
|
|
title={name}
|
|
loading="lazy"
|
|
decoding="async"
|
|
draggable={false}
|
|
onError={() => setError(true)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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>
|
|
{editing ? (
|
|
<div className="pf-skill-group__tags">
|
|
{items.map((s) => (
|
|
<span key={s.id} className="pf-skill-tag" data-level={s.level}>
|
|
{s.name}
|
|
<span className="pf-skill-tag__actions">
|
|
<button onClick={() => setSkillForm({...s})}>✎</button>
|
|
<button onClick={() => deleteSkill(s.id)}>×</button>
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="pf-skill-loop" aria-label={`${SKILL_CATEGORIES[cat]} 기술 스택`}>
|
|
{items.map((s) => (
|
|
<SkillLogoNode
|
|
key={s.id}
|
|
name={s.name}
|
|
slug={SKILL_LOGO_SLUGS[s.name]}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|