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:
2026-04-28 02:43:57 +09:00
parent 1bd680e47f
commit c68cee502a
4 changed files with 573 additions and 9 deletions

View File

@@ -378,6 +378,39 @@
color: #06b6d4;
}
/* ── Skill Logo Loop ─────────────────────────────────────────────────── */
.pf-skill-loop {
background: var(--surface-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 14px 0;
overflow: hidden;
}
.pf-skill-logo {
height: 36px;
width: auto;
display: block;
object-fit: contain;
pointer-events: none;
}
.pf-skill-fallback {
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 14px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-bright);
background: rgba(6, 182, 212, 0.12);
border: 1px solid rgba(6, 182, 212, 0.35);
border-radius: 999px;
white-space: nowrap;
}
/* ── Filter Bar ──────────────────────────────────────────────────────── */
.pf-filter-bar {

View File

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