From c68cee502a4a25a2aef4bdc06c1de104aa1232c4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 28 Apr 2026 02:43:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(portfolio):=20=EA=B8=B0=EC=88=A0=20?= =?UTF-8?q?=EC=8A=A4=ED=83=9D=EC=9D=84=20SimpleIcons=20=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=20=EB=AC=B4=ED=95=9C=20=EC=BA=90=EB=9F=AC=EC=85=80=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogoLoop 컴포넌트를 추가해 카테고리별(언어/프레임워크/인프라/도구) 4줄 가로 스크롤 캐러셀로 skill 로고를 표시. simpleicons CDN을 사용하며 매칭 안 되는 항목(APScheduler, KIS Open API)은 텍스트 칩으로 자동 fallback. 편집 모드에서는 기존 칩 UI를 유지해 편집·삭제 가능. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/LogoLoop.css | 147 +++++++++++++ src/components/LogoLoop.jsx | 322 +++++++++++++++++++++++++++++ src/pages/portfolio/Portfolio.css | 33 +++ src/pages/portfolio/ProfileTab.jsx | 80 ++++++- 4 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 src/components/LogoLoop.css create mode 100644 src/components/LogoLoop.jsx diff --git a/src/components/LogoLoop.css b/src/components/LogoLoop.css new file mode 100644 index 0000000..ac05717 --- /dev/null +++ b/src/components/LogoLoop.css @@ -0,0 +1,147 @@ +.logoloop { + position: relative; + overflow: hidden; +} + +.logoloop:not(.logoloop--vertical) { + overflow-x: hidden; +} + +.logoloop--vertical { + overflow-y: hidden; + height: 100%; + display: inline-block; +} + +.logoloop__track { + display: flex; + flex-direction: row; + width: max-content; + will-change: transform; + user-select: none; + position: relative; + z-index: 0; +} + +.logoloop__track--vertical { + flex-direction: column; + width: 100%; + height: max-content; +} + +@media (prefers-reduced-motion: reduce) { + .logoloop__track { + transform: none !important; + } +} + +.logoloop__list { + display: flex; + align-items: center; + list-style: none; + margin: 0; + padding: 0; +} + +.logoloop__list--vertical { + flex-direction: column; +} + +.logoloop__item { + flex: none; + font-size: var(--logoloop-logoHeight, 36px); + line-height: 1; +} + +.logoloop__list:not(.logoloop__list--vertical) .logoloop__item { + margin-right: var(--logoloop-gap, 32px); +} + +.logoloop__list--vertical .logoloop__item { + margin-bottom: var(--logoloop-gap, 32px); +} + +.logoloop__item--scalable { + overflow: visible; +} + +.logoloop__link { + display: inline-flex; + align-items: center; + text-decoration: none; + border-radius: 4px; + transition: opacity 0.2s linear; + color: inherit; +} + +.logoloop__link:hover { + opacity: 0.8; +} + +.logoloop__link:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.logoloop__node { + display: inline-flex; + align-items: center; +} + +.logoloop__node--scale, +.logoloop__img--scale { + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.logoloop__item--scalable:hover .logoloop__node--scale, +.logoloop__item--scalable:hover .logoloop__img--scale { + transform: scale(1.18); +} + +.logoloop__img { + height: var(--logoloop-logoHeight, 36px); + width: auto; + display: block; + object-fit: contain; + -webkit-user-drag: none; + pointer-events: none; + image-rendering: -webkit-optimize-contrast; +} + +.logoloop__fade { + position: absolute; + pointer-events: none; + z-index: 10; +} + +.logoloop__fade--left { + top: 0; + bottom: 0; + left: 0; + width: clamp(24px, 8%, 120px); + background: linear-gradient(to right, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%); +} + +.logoloop__fade--right { + top: 0; + bottom: 0; + right: 0; + width: clamp(24px, 8%, 120px); + background: linear-gradient(to left, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%); +} + +.logoloop__fade--top { + left: 0; + right: 0; + top: 0; + height: clamp(24px, 8%, 120px); + background: linear-gradient(to bottom, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%); +} + +.logoloop__fade--bottom { + left: 0; + right: 0; + bottom: 0; + height: clamp(24px, 8%, 120px); + background: linear-gradient(to top, var(--logoloop-fadeColor, var(--bg-primary, #0b0b0b)) 0%, transparent 100%); +} diff --git a/src/components/LogoLoop.jsx b/src/components/LogoLoop.jsx new file mode 100644 index 0000000..e0a8482 --- /dev/null +++ b/src/components/LogoLoop.jsx @@ -0,0 +1,322 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import './LogoLoop.css'; + +const ANIMATION_CONFIG = { + SMOOTH_TAU: 0.25, + MIN_COPIES: 2, + COPY_HEADROOM: 2, +}; + +const toCssLength = (value) => + typeof value === 'number' ? `${value}px` : value ?? undefined; + +const cx = (...parts) => parts.filter(Boolean).join(' '); + +function useResizeObserver(callback, elements, deps) { + useEffect(() => { + if (!window.ResizeObserver) { + const handler = () => callback(); + window.addEventListener('resize', handler); + callback(); + return () => window.removeEventListener('resize', handler); + } + const observers = elements.map((ref) => { + if (!ref.current) return null; + const observer = new ResizeObserver(callback); + observer.observe(ref.current); + return observer; + }); + callback(); + return () => { + observers.forEach((o) => o?.disconnect()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function useImageLoader(seqRef, onLoad, deps) { + useEffect(() => { + const images = seqRef.current?.querySelectorAll('img') ?? []; + if (images.length === 0) { + onLoad(); + return; + } + let remaining = images.length; + const handleLoad = () => { + remaining -= 1; + if (remaining === 0) onLoad(); + }; + images.forEach((img) => { + if (img.complete) { + handleLoad(); + } else { + img.addEventListener('load', handleLoad, { once: true }); + img.addEventListener('error', handleLoad, { once: true }); + } + }); + return () => { + images.forEach((img) => { + img.removeEventListener('load', handleLoad); + img.removeEventListener('error', handleLoad); + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) { + const rafRef = useRef(null); + const lastTsRef = useRef(null); + const offsetRef = useRef(0); + const velocityRef = useRef(0); + + useEffect(() => { + const track = trackRef.current; + if (!track) return; + + const prefersReduced = + typeof window !== 'undefined' && + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + const seqSize = isVertical ? seqHeight : seqWidth; + + if (seqSize > 0) { + offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize; + track.style.transform = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + } + + if (prefersReduced) { + track.style.transform = 'translate3d(0, 0, 0)'; + return () => { + lastTsRef.current = null; + }; + } + + const animate = (ts) => { + if (lastTsRef.current === null) lastTsRef.current = ts; + const dt = Math.max(0, ts - lastTsRef.current) / 1000; + lastTsRef.current = ts; + + const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity; + const easing = 1 - Math.exp(-dt / ANIMATION_CONFIG.SMOOTH_TAU); + velocityRef.current += (target - velocityRef.current) * easing; + + if (seqSize > 0) { + let next = offsetRef.current + velocityRef.current * dt; + next = ((next % seqSize) + seqSize) % seqSize; + offsetRef.current = next; + track.style.transform = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + } + rafRef.current = requestAnimationFrame(animate); + }; + + rafRef.current = requestAnimationFrame(animate); + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + rafRef.current = null; + lastTsRef.current = null; + }; + }, [trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]); +} + +export default function LogoLoop({ + logos, + speed = 60, + direction = 'left', + width = '100%', + logoHeight = 36, + gap = 32, + pauseOnHover = true, + hoverSpeed, + fadeOut = true, + fadeOutColor, + scaleOnHover = true, + ariaLabel = 'Skill logos', + className, + style, +}) { + const containerRef = useRef(null); + const trackRef = useRef(null); + const seqRef = useRef(null); + + const [seqWidth, setSeqWidth] = useState(0); + const [seqHeight, setSeqHeight] = useState(0); + const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES); + const [isHovered, setIsHovered] = useState(false); + + const effectiveHoverSpeed = useMemo(() => { + if (hoverSpeed !== undefined) return hoverSpeed; + if (pauseOnHover === true) return 0; + if (pauseOnHover === false) return undefined; + return 0; + }, [hoverSpeed, pauseOnHover]); + + const isVertical = direction === 'up' || direction === 'down'; + + const targetVelocity = useMemo(() => { + const magnitude = Math.abs(speed); + const dirMul = isVertical + ? direction === 'up' + ? 1 + : -1 + : direction === 'left' + ? 1 + : -1; + const speedMul = speed < 0 ? -1 : 1; + return magnitude * dirMul * speedMul; + }, [speed, direction, isVertical]); + + const updateDimensions = useCallback(() => { + const containerWidth = containerRef.current?.clientWidth ?? 0; + const seqRect = seqRef.current?.getBoundingClientRect?.(); + const sw = seqRect?.width ?? 0; + const sh = seqRect?.height ?? 0; + if (isVertical) { + const parentH = containerRef.current?.parentElement?.clientHeight ?? 0; + if (containerRef.current && parentH > 0) { + const h = Math.ceil(parentH); + if (containerRef.current.style.height !== `${h}px`) + containerRef.current.style.height = `${h}px`; + } + if (sh > 0) { + setSeqHeight(Math.ceil(sh)); + const viewport = containerRef.current?.clientHeight ?? parentH ?? sh; + const copies = Math.ceil(viewport / sh) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies)); + } + } else if (sw > 0) { + setSeqWidth(Math.ceil(sw)); + const copies = Math.ceil(containerWidth / sw) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies)); + } + }, [isVertical]); + + useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]); + useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]); + useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical); + + const cssVars = useMemo(() => ({ + '--logoloop-gap': `${gap}px`, + '--logoloop-logoHeight': `${logoHeight}px`, + ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }), + }), [gap, logoHeight, fadeOutColor]); + + const containerStyle = useMemo(() => ({ + width: isVertical + ? toCssLength(width) === '100%' + ? undefined + : toCssLength(width) + : toCssLength(width) ?? '100%', + ...cssVars, + ...style, + }), [width, cssVars, style, isVertical]); + + const handleEnter = useCallback(() => { + if (effectiveHoverSpeed !== undefined) setIsHovered(true); + }, [effectiveHoverSpeed]); + const handleLeave = useCallback(() => { + if (effectiveHoverSpeed !== undefined) setIsHovered(false); + }, [effectiveHoverSpeed]); + + const renderItem = (item, key) => { + const isNode = 'node' in item; + const inner = isNode ? ( + + {item.node} + + ) : ( + {item.alt + ); + const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title; + const wrapper = item.href ? ( + + {inner} + + ) : ( + inner + ); + return ( +
  • + {wrapper} +
  • + ); + }; + + const lists = useMemo( + () => + Array.from({ length: copyCount }, (_, i) => ( + + )), + // eslint-disable-next-line react-hooks/exhaustive-deps + [copyCount, logos, isVertical, scaleOnHover], + ); + + return ( +
    + {fadeOut && ( + <> +
    +
    + + )} +
    + {lists} +
    +
    + ); +} diff --git a/src/pages/portfolio/Portfolio.css b/src/pages/portfolio/Portfolio.css index 591ec5c..09fcc88 100644 --- a/src/pages/portfolio/Portfolio.css +++ b/src/pages/portfolio/Portfolio.css @@ -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 { diff --git a/src/pages/portfolio/ProfileTab.jsx b/src/pages/portfolio/ProfileTab.jsx index d201fdb..ca660e0 100644 --- a/src/pages/portfolio/ProfileTab.jsx +++ b/src/pages/portfolio/ProfileTab.jsx @@ -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 {name}; + } + return ( + {name} setError(true)} + /> + ); +} + +function buildSkillLogos(items) { + return items.map((s) => ({ + node: , + 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 && (

    {SKILL_CATEGORIES[cat]}

    -
    - {items.map((s) => ( - - {s.name} - {editing && ( + {editing ? ( +
    + {items.map((s) => ( + + {s.name} - )} - - ))} -
    +
    + ))} +
    + ) : ( +
    + +
    + )}
    ) )}