Compare commits
16 Commits
a50c6c8be2
...
573c0364bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 573c0364bb | |||
| 7f42ff3594 | |||
| 1c331f209a | |||
| c87e764063 | |||
| 80fcb07fc0 | |||
| a9a6808005 | |||
| 0a0ab05e41 | |||
| f6e78ac0ca | |||
| 60f17ff3e0 | |||
| 344caace3a | |||
| 9e5521d784 | |||
| 3b3e4a1ee1 | |||
| a9d9540f61 | |||
| c68cee502a | |||
| 1bd680e47f | |||
| 60655f8ba9 |
@@ -17,7 +17,7 @@
|
|||||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리 |
|
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격)<br>• 백엔드 스펙: `web-backend/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md` |
|
||||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||||
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||||
@@ -113,7 +113,8 @@ proxy: {
|
|||||||
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
|
||||||
| 에이전트 | WS | `/api/agent-office/ws` |
|
| 에이전트 | WS | `/api/agent-office/ws` |
|
||||||
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
| 부동산 | GET | `/api/realestate/announcements`, `/api/realestate/matches` |
|
||||||
| 부동산 | PUT | `/api/realestate/profile` |
|
| 부동산 | GET | `/api/realestate/profile` — 프로필 조회 |
|
||||||
|
| 부동산 | PUT | `/api/realestate/profile` — body: `{ preferred_districts: { "S": [...], "A": [...], "B": [...], "C": [...], "D": [...] }, min_match_score: int, notify_enabled: bool, ... }` |
|
||||||
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
|
| AI 큐레이터 | GET | `/api/lotto/briefing/latest`, `/api/lotto/curator/usage` |
|
||||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||||
|
|||||||
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,42 @@
|
|||||||
color: #06b6d4;
|
color: #06b6d4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Skill Logo Loop ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pf-skill-loop {
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 ──────────────────────────────────────────────────────── */
|
/* ── Filter Bar ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.pf-filter-bar {
|
.pf-filter-bar {
|
||||||
|
|||||||
@@ -6,6 +6,45 @@ const SKILL_CATEGORIES = { language: '언어', framework: '프레임워크', inf
|
|||||||
const emptyCareer = { category: 'company', organization: '', role: '', description: '', start_date: '', end_date: '', sort_order: 0 };
|
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 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 }) {
|
export default function ProfileTab({ data, editing, api, onRefresh }) {
|
||||||
const { profile, careers, skills } = data;
|
const { profile, careers, skills } = data;
|
||||||
const [editingProfile, setEditingProfile] = useState(null);
|
const [editingProfile, setEditingProfile] = useState(null);
|
||||||
@@ -180,19 +219,29 @@ export default function ProfileTab({ data, editing, api, onRefresh }) {
|
|||||||
items.length > 0 && (
|
items.length > 0 && (
|
||||||
<div key={cat} className="pf-skill-group">
|
<div key={cat} className="pf-skill-group">
|
||||||
<h4 className="pf-skill-group__title">{SKILL_CATEGORIES[cat]}</h4>
|
<h4 className="pf-skill-group__title">{SKILL_CATEGORIES[cat]}</h4>
|
||||||
|
{editing ? (
|
||||||
<div className="pf-skill-group__tags">
|
<div className="pf-skill-group__tags">
|
||||||
{items.map((s) => (
|
{items.map((s) => (
|
||||||
<span key={s.id} className="pf-skill-tag" data-level={s.level}>
|
<span key={s.id} className="pf-skill-tag" data-level={s.level}>
|
||||||
{s.name}
|
{s.name}
|
||||||
{editing && (
|
|
||||||
<span className="pf-skill-tag__actions">
|
<span className="pf-skill-tag__actions">
|
||||||
<button onClick={() => setSkillForm({...s})}>✎</button>
|
<button onClick={() => setSkillForm({...s})}>✎</button>
|
||||||
<button onClick={() => deleteSkill(s.id)}>×</button>
|
<button onClick={() => deleteSkill(s.id)}>×</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ const BASE = '/api/profile';
|
|||||||
|
|
||||||
async function apiFetch(path, options = {}) {
|
async function apiFetch(path, options = {}) {
|
||||||
const res = await fetch(`${BASE}${path}`, {
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
|
||||||
...options,
|
...options,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
|||||||
@@ -1178,3 +1178,359 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 신규: 자치구 5티어 + district 뱃지 (다크/네온 테마) =========== */
|
||||||
|
.sub-chip--district {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text-dim);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.sub-chip--tier {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
/* 페이지 accent 컬러 팔레트와 정렬: rose / orange / mint / cyan / purple */
|
||||||
|
.sub-chip--tier-S {
|
||||||
|
background: rgba(244, 63, 94, 0.14);
|
||||||
|
color: #fda4af;
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.4);
|
||||||
|
box-shadow: 0 0 12px rgba(244, 63, 94, 0.18);
|
||||||
|
}
|
||||||
|
.sub-chip--tier-A {
|
||||||
|
background: rgba(251, 146, 60, 0.14);
|
||||||
|
color: #fdba74;
|
||||||
|
border: 1px solid rgba(251, 146, 60, 0.4);
|
||||||
|
box-shadow: 0 0 12px rgba(251, 146, 60, 0.16);
|
||||||
|
}
|
||||||
|
.sub-chip--tier-B {
|
||||||
|
background: rgba(52, 211, 153, 0.14);
|
||||||
|
color: #6ee7b7;
|
||||||
|
border: 1px solid rgba(52, 211, 153, 0.4);
|
||||||
|
box-shadow: 0 0 12px rgba(52, 211, 153, 0.16);
|
||||||
|
}
|
||||||
|
.sub-chip--tier-C {
|
||||||
|
background: rgba(56, 189, 248, 0.14);
|
||||||
|
color: #7dd3fc;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||||
|
box-shadow: 0 0 12px rgba(56, 189, 248, 0.16);
|
||||||
|
}
|
||||||
|
.sub-chip--tier-D {
|
||||||
|
background: rgba(192, 132, 252, 0.14);
|
||||||
|
color: #d8b4fe;
|
||||||
|
border: 1px solid rgba(192, 132, 252, 0.4);
|
||||||
|
box-shadow: 0 0 12px rgba(192, 132, 252, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 신규: DistrictTierEditor (다크/glass + rose accent) =========== */
|
||||||
|
.dte-pool {
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
transition: background 0.2s var(--ease-out), border-color 0.2s var(--ease-out), box-shadow 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
.dte-pool--over {
|
||||||
|
background: rgba(244, 63, 94, 0.06);
|
||||||
|
border-color: rgba(244, 63, 94, 0.5);
|
||||||
|
border-style: solid;
|
||||||
|
box-shadow: 0 0 24px rgba(244, 63, 94, 0.12), inset 0 0 24px rgba(244, 63, 94, 0.06);
|
||||||
|
}
|
||||||
|
.dte-pool__title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-subscription);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
}
|
||||||
|
.dte-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.dte-chip {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
transition: background 0.15s var(--ease-out), border-color 0.15s var(--ease-out), transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
|
||||||
|
}
|
||||||
|
.dte-chip:hover {
|
||||||
|
border-color: rgba(244, 63, 94, 0.4);
|
||||||
|
background: rgba(244, 63, 94, 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0 12px rgba(244, 63, 94, 0.18);
|
||||||
|
}
|
||||||
|
.dte-chip:active { cursor: grabbing; transform: translateY(0); }
|
||||||
|
.dte-chip__remove {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: color 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
.dte-chip__remove:hover { color: var(--accent-subscription); opacity: 1; }
|
||||||
|
|
||||||
|
.dte-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.dte-zone {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 140px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
transition: background 0.2s var(--ease-out), border-color 0.2s var(--ease-out), box-shadow 0.2s var(--ease-out);
|
||||||
|
}
|
||||||
|
.dte-zone--over {
|
||||||
|
background: rgba(244, 63, 94, 0.06);
|
||||||
|
border-color: rgba(244, 63, 94, 0.5);
|
||||||
|
box-shadow: 0 0 24px rgba(244, 63, 94, 0.12), inset 0 0 24px rgba(244, 63, 94, 0.06);
|
||||||
|
}
|
||||||
|
.dte-zone__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.dte-zone__weight {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.7;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.dte-zone__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일 read-only 뷰 */
|
||||||
|
.dte-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--line-subtle);
|
||||||
|
}
|
||||||
|
.dte-row:last-of-type { border-bottom: 0; }
|
||||||
|
.dte-row__list {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.dte-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.dte-mobile-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed var(--line-subtle);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 신규: NotificationSettings (다크/rose glow) =================== */
|
||||||
|
.ns-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.ns-row--column {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ns-row__label {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-bright);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.ns-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.sub-toggle {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.25s var(--ease-out), border-color 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sub-toggle::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--text-dim);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.25s var(--ease-spring), background 0.25s var(--ease-out);
|
||||||
|
}
|
||||||
|
.sub-toggle:checked {
|
||||||
|
background: rgba(244, 63, 94, 0.25);
|
||||||
|
border-color: rgba(244, 63, 94, 0.5);
|
||||||
|
box-shadow: 0 0 16px rgba(244, 63, 94, 0.3);
|
||||||
|
}
|
||||||
|
.sub-toggle:checked::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
background: var(--accent-subscription);
|
||||||
|
box-shadow: 0 0 8px rgba(244, 63, 94, 0.6);
|
||||||
|
}
|
||||||
|
.sub-toggle__label {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
input.sub-toggle:checked + .sub-toggle__label { color: var(--accent-subscription); }
|
||||||
|
|
||||||
|
.ns-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ns-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--accent-subscription);
|
||||||
|
border: 2px solid var(--bg-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 12px rgba(244, 63, 94, 0.5);
|
||||||
|
transition: transform 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out);
|
||||||
|
}
|
||||||
|
.ns-slider::-webkit-slider-thumb:hover { transform: scale(1.15); box-shadow: 0 0 18px rgba(244, 63, 94, 0.7); }
|
||||||
|
.ns-slider::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--accent-subscription);
|
||||||
|
border: 2px solid var(--bg-secondary);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 12px rgba(244, 63, 94, 0.5);
|
||||||
|
}
|
||||||
|
.ns-slider:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ns-slider:disabled::-webkit-slider-thumb { background: var(--text-muted); box-shadow: none; }
|
||||||
|
.ns-scale {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.ns-hint {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border-left: 2px solid var(--accent-subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 신규: 매칭 분석 섹션 (다크/glass) ============================== */
|
||||||
|
.sub-match-analysis {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-top: 16px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sub-match-analysis::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 3px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(180deg, var(--accent-subscription), transparent);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.sub-match-analysis__score {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-subscription);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-shadow: 0 0 24px rgba(244, 63, 94, 0.35);
|
||||||
|
}
|
||||||
|
.sub-match-analysis__reasons {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
.sub-match-analysis__reasons li {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
.sub-match-analysis__reasons li::marker {
|
||||||
|
color: var(--accent-subscription);
|
||||||
|
}
|
||||||
|
.sub-match-analysis__elig {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모바일 dte-grid → 1칼럼 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.dte-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|||||||
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
|
||||||
import PullToRefresh from '../../components/PullToRefresh';
|
import PullToRefresh from '../../components/PullToRefresh';
|
||||||
import FAB from '../../components/FAB';
|
import FAB from '../../components/FAB';
|
||||||
|
import DistrictTierEditor from './components/DistrictTierEditor';
|
||||||
|
import NotificationSettings from './components/NotificationSettings';
|
||||||
import './Subscription.css';
|
import './Subscription.css';
|
||||||
|
|
||||||
// ── 상수 ───────────────────────────────────────────────────────────────────────
|
// ── 상수 ───────────────────────────────────────────────────────────────────────
|
||||||
@@ -30,9 +32,23 @@ const DEFAULT_PROFILE = {
|
|||||||
has_newborn: false, is_first_home: false, income_level: '',
|
has_newborn: false, is_first_home: false, income_level: '',
|
||||||
preferred_regions: '', preferred_types: '',
|
preferred_regions: '', preferred_types: '',
|
||||||
min_area: '', max_area: '', max_price: '',
|
min_area: '', max_area: '', max_price: '',
|
||||||
|
// 신규 (자치구 5티어 + 알림 설정)
|
||||||
|
preferred_districts: {},
|
||||||
|
min_match_score: 70,
|
||||||
|
notify_enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 유틸 ──────────────────────────────────────────────────────────────────────
|
// ── 유틸 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 매칭 reasons에서 자치구 티어를 추출 ("자치구 S티어: 강남구 (+25)" → "S")
|
||||||
|
function extractTier(reasons) {
|
||||||
|
for (const r of reasons || []) {
|
||||||
|
const m = r.match(/자치구 ([SABCD])티어/);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const fmt = (d) => {
|
const fmt = (d) => {
|
||||||
if (!d) return '-';
|
if (!d) return '-';
|
||||||
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
|
||||||
@@ -341,6 +357,17 @@ function AnnouncementCard({ item, isSelected, onClick, onBookmark }) {
|
|||||||
{item.match_score}점
|
{item.match_score}점
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{item.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{item.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(item.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onBookmark?.(item.id); }}
|
onClick={(e) => { e.stopPropagation(); onBookmark?.(item.id); }}
|
||||||
@@ -588,6 +615,39 @@ function AnnouncementDetail({ item, onBookmark }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{item.match_score !== undefined && item.match_score !== null && (
|
||||||
|
<div className="sub-match-analysis">
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow">매칭 분석</p>
|
||||||
|
<span className="sub-match-analysis__score">
|
||||||
|
⭐ {item.match_score}<span style={{ fontSize: 14, color: "var(--text-muted)" }}> / 100</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.match_reasons && item.match_reasons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>💡 매칭 사유</p>
|
||||||
|
<ul className="sub-match-analysis__reasons">
|
||||||
|
{item.match_reasons.map((r, idx) => (
|
||||||
|
<li key={idx}>{r}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.eligible_types && item.eligible_types.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="sub-panel__eyebrow" style={{ marginTop: 8 }}>✓ 신청 자격</p>
|
||||||
|
<div className="sub-match-analysis__elig">
|
||||||
|
{item.eligible_types.map(t => (
|
||||||
|
<span key={t} className="sub-chip">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -869,6 +929,17 @@ function MatchesTab() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{match.ann_status && <StatusBadge status={match.ann_status} />}
|
{match.ann_status && <StatusBadge status={match.ann_status} />}
|
||||||
|
{match.district && (
|
||||||
|
<span className="sub-chip sub-chip--district">{match.district}</span>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const tier = extractTier(match.match_reasons);
|
||||||
|
return tier ? (
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
|
||||||
|
{tier}티어
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<p className="sub-card__address" style={{ margin: 0 }}>
|
<p className="sub-card__address" style={{ margin: 0 }}>
|
||||||
{match.region_name || '-'}
|
{match.region_name || '-'}
|
||||||
@@ -1012,6 +1083,13 @@ function ProfileTab() {
|
|||||||
if (payload.preferred_regions.length === 0) payload.preferred_regions = null;
|
if (payload.preferred_regions.length === 0) payload.preferred_regions = null;
|
||||||
if (payload.preferred_types.length === 0) payload.preferred_types = null;
|
if (payload.preferred_types.length === 0) payload.preferred_types = null;
|
||||||
|
|
||||||
|
// 신규: preferred_districts (객체), min_match_score, notify_enabled
|
||||||
|
payload.preferred_districts = profile.preferred_districts && typeof profile.preferred_districts === "object"
|
||||||
|
? profile.preferred_districts
|
||||||
|
: {};
|
||||||
|
payload.min_match_score = profile.min_match_score ?? null;
|
||||||
|
payload.notify_enabled = profile.notify_enabled ?? null;
|
||||||
|
|
||||||
const updated = await apiPut('/api/realestate/profile', payload);
|
const updated = await apiPut('/api/realestate/profile', payload);
|
||||||
if (updated && Object.keys(updated).length > 0) {
|
if (updated && Object.keys(updated).length > 0) {
|
||||||
// Convert arrays back to comma-separated strings for display
|
// Convert arrays back to comma-separated strings for display
|
||||||
@@ -1289,6 +1367,19 @@ function ProfileTab() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 자치구 5티어 */}
|
||||||
|
<DistrictTierEditor
|
||||||
|
value={profile.preferred_districts}
|
||||||
|
onChange={(next) => setProfile(prev => ({ ...prev, preferred_districts: next }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 알림 설정 */}
|
||||||
|
<NotificationSettings
|
||||||
|
minScore={profile.min_match_score ?? 70}
|
||||||
|
notifyEnabled={profile.notify_enabled ?? true}
|
||||||
|
onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
170
src/pages/subscription/components/DistrictTierEditor.jsx
Normal file
170
src/pages/subscription/components/DistrictTierEditor.jsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const SEOUL_DISTRICTS = [
|
||||||
|
"강남구","강동구","강북구","강서구","관악구",
|
||||||
|
"광진구","구로구","금천구","노원구","도봉구",
|
||||||
|
"동대문구","동작구","마포구","서대문구","서초구",
|
||||||
|
"성동구","성북구","송파구","양천구","영등포구",
|
||||||
|
"용산구","은평구","종로구","중구","중랑구",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TIERS = [
|
||||||
|
{ key: "S", label: "S", weight: "100%" },
|
||||||
|
{ key: "A", label: "A", weight: "80%" },
|
||||||
|
{ key: "B", label: "B", weight: "60%" },
|
||||||
|
{ key: "C", label: "C", weight: "40%" },
|
||||||
|
{ key: "D", label: "D", weight: "20%" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_TIERS = { S: [], A: [], B: [], C: [], D: [] };
|
||||||
|
|
||||||
|
function useIsDesktop() {
|
||||||
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
|
typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const mq = window.matchMedia("(min-width: 768px)");
|
||||||
|
const handler = (e) => setIsDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
return isDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DistrictTierEditor({ value, onChange }) {
|
||||||
|
const isDesktop = useIsDesktop();
|
||||||
|
const [dragOver, setDragOver] = useState(null); // 현재 hover 중인 zone key
|
||||||
|
|
||||||
|
const current = value && Object.keys(value).length > 0 ? value : EMPTY_TIERS;
|
||||||
|
|
||||||
|
const unassigned = SEOUL_DISTRICTS.filter(
|
||||||
|
d => !TIERS.some(t => (current[t.key] || []).includes(d))
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveDistrict = (district, targetTier /* null = 미할당 */) => {
|
||||||
|
const next = { S: [], A: [], B: [], C: [], D: [] };
|
||||||
|
for (const t of Object.keys(next)) {
|
||||||
|
next[t] = (current[t] || []).filter(d => d !== district);
|
||||||
|
}
|
||||||
|
if (targetTier) {
|
||||||
|
next[targetTier] = [...next[targetTier], district];
|
||||||
|
}
|
||||||
|
onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e, district) => {
|
||||||
|
e.dataTransfer.setData("text/district", district);
|
||||||
|
e.dataTransfer.effectAllowed = "move";
|
||||||
|
};
|
||||||
|
const onDragOver = (e, key) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
if (dragOver !== key) setDragOver(key);
|
||||||
|
};
|
||||||
|
const onDragLeave = (e) => {
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget)) return;
|
||||||
|
setDragOver(null);
|
||||||
|
};
|
||||||
|
const onDrop = (e, targetTier /* null = 미할당 */) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const district = e.dataTransfer.getData("text/district");
|
||||||
|
setDragOver(null);
|
||||||
|
if (district) moveDistrict(district, targetTier);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isDesktop) {
|
||||||
|
return (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||||
|
<h3>지역 5티어</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: "grid", gap: 10 }}>
|
||||||
|
{TIERS.map(t => (
|
||||||
|
<div key={t.key} className="dte-row dte-row--readonly">
|
||||||
|
<span className={`sub-chip sub-chip--tier sub-chip--tier-${t.key}`}>
|
||||||
|
{t.label} {t.weight}
|
||||||
|
</span>
|
||||||
|
<span className="dte-row__list">
|
||||||
|
{(current[t.key] || []).length === 0
|
||||||
|
? <span className="dte-empty">(없음)</span>
|
||||||
|
: (current[t.key] || []).join(", ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="dte-mobile-hint">✏️ 자치구 분류는 PC에서 편집할 수 있어요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<p className="sub-panel__eyebrow">자치구 우선순위</p>
|
||||||
|
<h3>지역 5티어 (드래그해서 분류)</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: "grid", gap: 12 }}>
|
||||||
|
{/* 미할당 풀 */}
|
||||||
|
<div
|
||||||
|
className={`dte-pool ${dragOver === "_unassigned" ? "dte-pool--over" : ""}`}
|
||||||
|
onDragOver={(e) => onDragOver(e, "_unassigned")}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, null)}
|
||||||
|
>
|
||||||
|
<p className="dte-pool__title">미할당 ({unassigned.length})</p>
|
||||||
|
<div className="dte-chips">
|
||||||
|
{unassigned.map(d => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, d)}
|
||||||
|
className="sub-chip sub-chip--district dte-chip"
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5티어 그리드 */}
|
||||||
|
<div className="dte-grid">
|
||||||
|
{TIERS.map(t => (
|
||||||
|
<div
|
||||||
|
key={t.key}
|
||||||
|
className={`dte-zone ${dragOver === t.key ? "dte-zone--over" : ""}`}
|
||||||
|
onDragOver={(e) => onDragOver(e, t.key)}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
onDrop={(e) => onDrop(e, t.key)}
|
||||||
|
>
|
||||||
|
<div className={`dte-zone__head sub-chip--tier-${t.key}`}>
|
||||||
|
{t.label} <span className="dte-zone__weight">{t.weight}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dte-zone__chips">
|
||||||
|
{(current[t.key] || []).map(d => (
|
||||||
|
<span
|
||||||
|
key={d}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, d)}
|
||||||
|
className="sub-chip sub-chip--district dte-chip"
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dte-chip__remove"
|
||||||
|
onClick={() => moveDistrict(d, null)}
|
||||||
|
aria-label={`${d} 미할당으로`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/pages/subscription/components/NotificationSettings.jsx
Normal file
52
src/pages/subscription/components/NotificationSettings.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export default function NotificationSettings({ minScore, notifyEnabled, onChange }) {
|
||||||
|
const score = minScore ?? 70;
|
||||||
|
const enabled = notifyEnabled ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sub-panel">
|
||||||
|
<div className="sub-panel__head">
|
||||||
|
<p className="sub-panel__eyebrow">알림 설정</p>
|
||||||
|
<h3>🔔 텔레그램 알림</h3>
|
||||||
|
</div>
|
||||||
|
<div className="sub-panel__body" style={{ display: "grid", gap: 16 }}>
|
||||||
|
<label className="ns-row">
|
||||||
|
<span className="ns-row__label">텔레그램 알림</span>
|
||||||
|
<span className="ns-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sub-toggle"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => onChange({ notify_enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="sub-toggle__label">{enabled ? "ON" : "OFF"}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="ns-row ns-row--column">
|
||||||
|
<span className="ns-row__label">매칭 임계값 — {score}점</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value={score}
|
||||||
|
onChange={(e) => onChange({ min_match_score: Number(e.target.value) })}
|
||||||
|
className="ns-slider"
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<div className="ns-scale">
|
||||||
|
<span>0</span>
|
||||||
|
<span>50</span>
|
||||||
|
<span>100</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="ns-hint">
|
||||||
|
{enabled
|
||||||
|
? `💡 ${score}점 이상 매치 시 텔레그램에 자동 알림합니다.`
|
||||||
|
: "⚠️ 알림 OFF — 임계값을 통과한 매칭이 있어도 메시지가 발송되지 않습니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,15 @@ export const navLinks = [
|
|||||||
icon: <IconHome />,
|
icon: <IconHome />,
|
||||||
accent: '#f7a8a5',
|
accent: '#f7a8a5',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'portfolio',
|
||||||
|
label: 'Portfolio',
|
||||||
|
path: '/portfolio',
|
||||||
|
subtitle: 'RESUME',
|
||||||
|
description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
|
||||||
|
icon: <IconPortfolio />,
|
||||||
|
accent: '#06b6d4',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'blog',
|
id: 'blog',
|
||||||
label: 'Blog',
|
label: 'Blog',
|
||||||
@@ -47,6 +56,15 @@ export const navLinks = [
|
|||||||
icon: <IconBlog />,
|
icon: <IconBlog />,
|
||||||
accent: '#c084fc',
|
accent: '#c084fc',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'travel',
|
||||||
|
label: 'Travel',
|
||||||
|
path: '/travel',
|
||||||
|
subtitle: 'VISUAL DIARY',
|
||||||
|
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
||||||
|
icon: <IconTravel />,
|
||||||
|
accent: '#fb923c',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'lotto',
|
id: 'lotto',
|
||||||
label: 'Lotto',
|
label: 'Lotto',
|
||||||
@@ -65,24 +83,6 @@ export const navLinks = [
|
|||||||
icon: <IconStock />,
|
icon: <IconStock />,
|
||||||
accent: '#60a5fa',
|
accent: '#60a5fa',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'realestate',
|
|
||||||
label: 'Realestate',
|
|
||||||
path: '/realestate',
|
|
||||||
subtitle: '부동산',
|
|
||||||
description: '청약 공고 자동 수집, 매칭, 프로필 기반 자격 분석',
|
|
||||||
icon: <IconBuilding />,
|
|
||||||
accent: '#f43f5e',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'travel',
|
|
||||||
label: 'Travel',
|
|
||||||
path: '/travel',
|
|
||||||
subtitle: 'VISUAL DIARY',
|
|
||||||
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
|
|
||||||
icon: <IconTravel />,
|
|
||||||
accent: '#fb923c',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'music',
|
id: 'music',
|
||||||
label: 'Music',
|
label: 'Music',
|
||||||
@@ -92,6 +92,15 @@ export const navLinks = [
|
|||||||
icon: <IconMusic />,
|
icon: <IconMusic />,
|
||||||
accent: '#f5a623',
|
accent: '#f5a623',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'realestate',
|
||||||
|
label: 'Realestate',
|
||||||
|
path: '/realestate',
|
||||||
|
subtitle: '부동산',
|
||||||
|
description: '청약 공고 자동 수집, 매칭, 프로필 기반 자격 분석',
|
||||||
|
icon: <IconBuilding />,
|
||||||
|
accent: '#f43f5e',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'blog-lab',
|
id: 'blog-lab',
|
||||||
label: 'Blog Lab',
|
label: 'Blog Lab',
|
||||||
@@ -101,15 +110,6 @@ export const navLinks = [
|
|||||||
icon: <IconBlogMarketing />,
|
icon: <IconBlogMarketing />,
|
||||||
accent: '#10b981',
|
accent: '#10b981',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'lab',
|
|
||||||
label: 'Lab',
|
|
||||||
path: '/lab',
|
|
||||||
subtitle: 'STREAM',
|
|
||||||
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
|
||||||
icon: <IconLab />,
|
|
||||||
accent: '#fbbf24',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'todo',
|
id: 'todo',
|
||||||
label: 'Todo',
|
label: 'Todo',
|
||||||
@@ -119,15 +119,6 @@ export const navLinks = [
|
|||||||
icon: <IconTodo />,
|
icon: <IconTodo />,
|
||||||
accent: '#f472b6',
|
accent: '#f472b6',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'portfolio',
|
|
||||||
label: 'Portfolio',
|
|
||||||
path: '/portfolio',
|
|
||||||
subtitle: 'RESUME',
|
|
||||||
description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
|
|
||||||
icon: <IconPortfolio />,
|
|
||||||
accent: '#06b6d4',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'agent-office',
|
id: 'agent-office',
|
||||||
label: 'Agent Office',
|
label: 'Agent Office',
|
||||||
@@ -137,6 +128,15 @@ export const navLinks = [
|
|||||||
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
|
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
|
||||||
accent: '#8b5cf6',
|
accent: '#8b5cf6',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'lab',
|
||||||
|
label: 'Lab',
|
||||||
|
path: '/lab',
|
||||||
|
subtitle: 'STREAM',
|
||||||
|
description: '실험적인 UI/UX 효과를 테스트하는 공간',
|
||||||
|
icon: <IconLab />,
|
||||||
|
accent: '#fbbf24',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const appRoutes = [
|
export const appRoutes = [
|
||||||
|
|||||||
Reference in New Issue
Block a user