feat(portfolio): 기술 스택을 SimpleIcons 로고 무한 캐러셀로 표시
LogoLoop 컴포넌트를 추가해 카테고리별(언어/프레임워크/인프라/도구) 4줄 가로 스크롤 캐러셀로 skill 로고를 표시. simpleicons CDN을 사용하며 매칭 안 되는 항목(APScheduler, KIS Open API)은 텍스트 칩으로 자동 fallback. 편집 모드에서는 기존 칩 UI를 유지해 편집·삭제 가능. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import LogoLoop from '../../components/LogoLoop';
|
||||
|
||||
const CAREER_CATEGORIES = { company: '회사', education: '교육', etc: '기타' };
|
||||
const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', infra: '인프라', tool: '도구' };
|
||||
@@ -6,6 +7,53 @@ const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', inf
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSkillLogos(items) {
|
||||
return items.map((s) => ({
|
||||
node: <SkillLogoNode name={s.name} slug={SKILL_LOGO_SLUGS[s.name]} />,
|
||||
ariaLabel: s.name,
|
||||
title: s.name,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function ProfileTab({ data, editing, api, onRefresh }) {
|
||||
const { profile, careers, skills } = data;
|
||||
const [editingProfile, setEditingProfile] = useState(null);
|
||||
@@ -180,19 +228,33 @@ export default function ProfileTab({ data, editing, api, onRefresh }) {
|
||||
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 && (
|
||||
{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>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="pf-skill-loop">
|
||||
<LogoLoop
|
||||
logos={buildSkillLogos(items)}
|
||||
speed={40}
|
||||
logoHeight={36}
|
||||
gap={48}
|
||||
pauseOnHover
|
||||
fadeOut
|
||||
fadeOutColor="var(--bg-primary, #0b0b0b)"
|
||||
scaleOnHover
|
||||
ariaLabel={`${SKILL_CATEGORIES[cat]} 기술 스택`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user