Compare commits

16 Commits

Author SHA1 Message Date
573c0364bb style(subscription): 5티어/드래그/토글/슬라이더 다크 네온 테마 정렬
- 5티어 뱃지: light pastel → 페이지 accent 팔레트 + neon glow
  S=rose / A=orange / B=mint / C=cyan / D=purple (모두 반투명 bg + bright text + glow)
- DistrictTierEditor: surface-card glass + rose dragOver glow
- 자치구 칩: surface-raised + rose hover lift/glow
- sub-toggle: 다크 호환 + rose 활성 glow
- ns-slider: custom thumb (rose + glow + scale on hover)
- 매칭 분석: surface-card + rose 사이드 그라데이션 + 점수 text-shadow
- 모든 텍스트는 --text/--text-bright/--text-dim/--text-muted 토큰
- font-family: --font-display(--font-body)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:36:01 +09:00
7f42ff3594 Merge branch 'feat/realestate-frontend-targeting'
청약 페이지 자치구 5티어 + 알림 설정 UI — 9 task TDD 구현
- DistrictTierEditor: 데스크톱 드래그&드롭 + 모바일 read-only
- NotificationSettings: 임계값 슬라이더 + 알림 토글
- AnnouncementCard / MatchesTab: district + 5티어 뱃지
- AnnouncementDetail: 매칭 분석 섹션 (점수 + 사유 + 자격)
- 백엔드 스펙: web-backend 2026-04-28-realestate-targeting-enhancement
- 빌드 clean, 린트 baseline 유지

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:37:10 +09:00
1c331f209a fix(subscription): CLAUDE.md districts shape + dragLeave 정확도
preferred_districts 문서 형태를 백엔드 실제 구조(tier-keyed Dict[str, List[str]])로 수정.
onDragLeave가 자식 요소로 커서 이동 시 flicker 발생하던 문제 수정(relatedTarget 체크).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:31:24 +09:00
c87e764063 docs(web-ui): 청약 5티어 + 알림 설정 문서 업데이트
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:26:19 +09:00
80fcb07fc0 feat(subscription): MatchesTab 카드에 district + 5티어 뱃지
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 20:26:15 +09:00
a9a6808005 feat(subscription): AnnouncementDetail에 매칭 분석 섹션
match_score가 있는 공고에 한해 매칭 분석 섹션을 상세 패널 하단에 노출.
점수·매칭 사유·신청 자격 타입을 조건부 렌더링.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:08:10 +09:00
0a0ab05e41 feat(subscription): AnnouncementCard에 district + 5티어 뱃지 2026-04-28 11:06:32 +09:00
f6e78ac0ca feat(subscription): 5티어 뱃지 + 드래그영역 + 토글 + 슬라이더 스타일
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:04:13 +09:00
60f17ff3e0 feat(subscription): ProfileTab에 5티어/알림 설정 통합
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 11:01:41 +09:00
344caace3a feat(subscription): NotificationSettings — 임계값 슬라이더 + 알림 토글 2026-04-28 10:58:45 +09:00
9e5521d784 feat(subscription): DistrictTierEditor — 자치구 5티어 드래그앤드롭
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 10:55:54 +09:00
3b3e4a1ee1 feat(subscription): DEFAULT_PROFILE 신규 3필드 + extractTier 헬퍼 2026-04-28 10:51:45 +09:00
a9d9540f61 fix(portfolio): 기술 스택 로고를 정적 4줄 레이아웃으로 변경
LogoLoop 무한 캐러셀이 항목 수가 적은 카테고리에서 반복돼 시각적으로 산만한
문제. 카테고리별로 단순 flex-wrap 줄로 정적 표시. SkillLogoNode와 fallback
로직은 유지. LogoLoop 컴포넌트 자체는 다른 페이지에서 재사용 여지를 위해 보존.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:49:00 +09:00
c68cee502a 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>
2026-04-28 02:43:57 +09:00
1bd680e47f chore(nav): 사이드바 메뉴 순서 재배치
Home → Portfolio → Blog → Travel → Lotto → Stock → Music → Realestate
→ Blog Lab → Todo → Agent Office → Lab 순으로 navLinks 재정렬.
BottomNav도 동일 source를 사용해 모바일 더보기 패널까지 함께 반영됨.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:28:16 +09:00
60655f8ba9 fix(portfolio): apiFetch에서 Content-Type 헤더가 options.headers에 덮여 사라지는 문제 수정
PUT /api/profile/profile 등 인증 헤더를 함께 보내는 요청에서 Content-Type이
빠져 FastAPI가 body를 JSON으로 파싱하지 못해 422를 반환하던 문제. spread 순서를
뒤집어 options 펼친 뒤 headers를 마지막에 머지하도록 수정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 02:01:14 +09:00
11 changed files with 1272 additions and 48 deletions

View File

@@ -17,7 +17,7 @@
| `/lotto` | `Lotto` | 로또 추천/통계 |
| `/stock` | `Stock` | 주식 뉴스/지수 |
| `/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` | 관심 단지 정보 |
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
@@ -113,7 +113,8 @@ proxy: {
| 에이전트 | POST | `/api/agent-office/command`, `/api/agent-office/approve` |
| 에이전트 | WS | `/api/agent-office/ws` |
| 부동산 | 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` |
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |

147
src/components/LogoLoop.css Normal file
View 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
View 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>
);
}

View File

@@ -378,6 +378,42 @@
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 ──────────────────────────────────────────────────────── */
.pf-filter-bar {

View File

@@ -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 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 }) {
const { profile, careers, skills } = data;
const [editingProfile, setEditingProfile] = useState(null);
@@ -180,19 +219,29 @@ 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>
{editing ? (
<div className="pf-skill-group__tags">
{items.map((s) => (
<span key={s.id} className="pf-skill-tag" data-level={s.level}>
{s.name}
{editing && (
<span className="pf-skill-tag__actions">
<button onClick={() => setSkillForm({...s})}>&#9998;</button>
<button onClick={() => deleteSkill(s.id)}>&times;</button>
</span>
)}
</span>
))}
</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>
)
)}

View File

@@ -4,8 +4,8 @@ const BASE = '/api/profile';
async function apiFetch(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...options.headers },
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));

View File

@@ -1178,3 +1178,359 @@
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;
}
}

View File

@@ -2,6 +2,8 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { apiGet, apiPost, apiPut, apiDelete } from '../../api';
import PullToRefresh from '../../components/PullToRefresh';
import FAB from '../../components/FAB';
import DistrictTierEditor from './components/DistrictTierEditor';
import NotificationSettings from './components/NotificationSettings';
import './Subscription.css';
// ── 상수 ───────────────────────────────────────────────────────────────────────
@@ -30,9 +32,23 @@ const DEFAULT_PROFILE = {
has_newborn: false, is_first_home: false, income_level: '',
preferred_regions: '', preferred_types: '',
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) => {
if (!d) return '-';
return new Date(d).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
@@ -341,6 +357,17 @@ function AnnouncementCard({ item, isSelected, onClick, onBookmark }) {
{item.match_score}
</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>
<button
onClick={(e) => { e.stopPropagation(); onBookmark?.(item.id); }}
@@ -588,6 +615,39 @@ function AnnouncementDetail({ item, onBookmark }) {
</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>
);
}
@@ -869,6 +929,17 @@ function MatchesTab() {
</span>
)}
{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>
<p className="sub-card__address" style={{ margin: 0 }}>
{match.region_name || '-'}
@@ -1012,6 +1083,13 @@ function ProfileTab() {
if (payload.preferred_regions.length === 0) payload.preferred_regions = 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);
if (updated && Object.keys(updated).length > 0) {
// Convert arrays back to comma-separated strings for display
@@ -1289,6 +1367,19 @@ function ProfileTab() {
</label>
</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>

View 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>
);
}

View 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>
);
}

View File

@@ -38,6 +38,15 @@ export const navLinks = [
icon: <IconHome />,
accent: '#f7a8a5',
},
{
id: 'portfolio',
label: 'Portfolio',
path: '/portfolio',
subtitle: 'RESUME',
description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
icon: <IconPortfolio />,
accent: '#06b6d4',
},
{
id: 'blog',
label: 'Blog',
@@ -47,6 +56,15 @@ export const navLinks = [
icon: <IconBlog />,
accent: '#c084fc',
},
{
id: 'travel',
label: 'Travel',
path: '/travel',
subtitle: 'VISUAL DIARY',
description: '여행에서 담은 색과 장면을 전시하는 갤러리',
icon: <IconTravel />,
accent: '#fb923c',
},
{
id: 'lotto',
label: 'Lotto',
@@ -65,24 +83,6 @@ export const navLinks = [
icon: <IconStock />,
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',
label: 'Music',
@@ -92,6 +92,15 @@ export const navLinks = [
icon: <IconMusic />,
accent: '#f5a623',
},
{
id: 'realestate',
label: 'Realestate',
path: '/realestate',
subtitle: '부동산',
description: '청약 공고 자동 수집, 매칭, 프로필 기반 자격 분석',
icon: <IconBuilding />,
accent: '#f43f5e',
},
{
id: 'blog-lab',
label: 'Blog Lab',
@@ -101,15 +110,6 @@ export const navLinks = [
icon: <IconBlogMarketing />,
accent: '#10b981',
},
{
id: 'lab',
label: 'Lab',
path: '/lab',
subtitle: 'STREAM',
description: '실험적인 UI/UX 효과를 테스트하는 공간',
icon: <IconLab />,
accent: '#fbbf24',
},
{
id: 'todo',
label: 'Todo',
@@ -119,15 +119,6 @@ export const navLinks = [
icon: <IconTodo />,
accent: '#f472b6',
},
{
id: 'portfolio',
label: 'Portfolio',
path: '/portfolio',
subtitle: 'RESUME',
description: '개인 포트폴리오 — 프로필, 이력, 프로젝트 쇼케이스',
icon: <IconPortfolio />,
accent: '#06b6d4',
},
{
id: 'agent-office',
label: 'Agent Office',
@@ -137,6 +128,15 @@ export const navLinks = [
icon: <span style={{fontSize:'1.2em'}}>🏢</span>,
accent: '#8b5cf6',
},
{
id: 'lab',
label: 'Lab',
path: '/lab',
subtitle: 'STREAM',
description: '실험적인 UI/UX 효과를 테스트하는 공간',
icon: <IconLab />,
accent: '#fbbf24',
},
];
export const appRoutes = [