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:
147
src/components/LogoLoop.css
Normal file
147
src/components/LogoLoop.css
Normal file
@@ -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%);
|
||||
}
|
||||
322
src/components/LogoLoop.jsx
Normal file
322
src/components/LogoLoop.jsx
Normal file
@@ -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 ? (
|
||||
<span
|
||||
className={cx('logoloop__node', scaleOnHover && 'logoloop__node--scale')}
|
||||
aria-hidden={!!item.href && !item.ariaLabel}
|
||||
>
|
||||
{item.node}
|
||||
</span>
|
||||
) : (
|
||||
<img
|
||||
className={cx('logoloop__img', scaleOnHover && 'logoloop__img--scale')}
|
||||
src={item.src}
|
||||
srcSet={item.srcSet}
|
||||
sizes={item.sizes}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
alt={item.alt ?? ''}
|
||||
title={item.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title;
|
||||
const wrapper = item.href ? (
|
||||
<a
|
||||
className="logoloop__link"
|
||||
href={item.href}
|
||||
aria-label={itemAriaLabel || 'logo link'}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
inner
|
||||
);
|
||||
return (
|
||||
<li
|
||||
className={cx('logoloop__item', scaleOnHover && 'logoloop__item--scalable')}
|
||||
key={key}
|
||||
role="listitem"
|
||||
>
|
||||
{wrapper}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const lists = useMemo(
|
||||
() =>
|
||||
Array.from({ length: copyCount }, (_, i) => (
|
||||
<ul
|
||||
className={cx('logoloop__list', isVertical && 'logoloop__list--vertical')}
|
||||
key={`copy-${i}`}
|
||||
role="list"
|
||||
aria-hidden={i > 0}
|
||||
ref={i === 0 ? seqRef : undefined}
|
||||
>
|
||||
{logos.map((it, idx) => renderItem(it, `${i}-${idx}`))}
|
||||
</ul>
|
||||
)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[copyCount, logos, isVertical, scaleOnHover],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cx('logoloop', isVertical && 'logoloop--vertical', className)}
|
||||
style={containerStyle}
|
||||
role="region"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{fadeOut && (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--top' : 'logoloop__fade--left')}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--bottom' : 'logoloop__fade--right')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={cx('logoloop__track', isVertical && 'logoloop__track--vertical')}
|
||||
ref={trackRef}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
{lists}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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