Files
web-page/src/pages/portfolio/ProfileTab.jsx
gahusb a9d9540f61 fix(portfolio): 기술 스택 로고를 정적 4줄 레이아웃으로 변경
LogoLoop 무한 캐러셀이 항목 수가 적은 카테고리에서 반복돼 시각적으로 산만한
문제. 카테고리별로 단순 flex-wrap 줄로 정적 표시. SkillLogoNode와 fallback
로직은 유지. LogoLoop 컴포넌트 자체는 다른 페이지에서 재사용 여지를 위해 보존.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:49:00 +09:00

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})}>&#9998;</button>
<button onClick={() => deleteSkill(s.id)}>&times;</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>
);
}