diff --git a/src/features/blog/ui/components/CategoryFilter/CategoryFilter.tsx b/src/features/blog/ui/components/CategoryFilter/CategoryFilter.tsx index 050fe38..76b7c21 100644 --- a/src/features/blog/ui/components/CategoryFilter/CategoryFilter.tsx +++ b/src/features/blog/ui/components/CategoryFilter/CategoryFilter.tsx @@ -30,7 +30,7 @@ export default function CategoryFilter({ key={category} onClick={() => onCategoryChange(category)} className={clsx( - 'inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]', + 'inline-flex min-h-11 items-center gap-2 rounded-full border px-4 py-2 text-sm transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]', 'active:translate-y-px', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-primary)]', activeCategory === category diff --git a/src/features/blog/ui/components/PostList/PostList.tsx b/src/features/blog/ui/components/PostList/PostList.tsx index 90dbccb..f1a1579 100644 --- a/src/features/blog/ui/components/PostList/PostList.tsx +++ b/src/features/blog/ui/components/PostList/PostList.tsx @@ -1,39 +1,61 @@ 'use client'; import { motion } from 'framer-motion'; +import type { Variants } from 'framer-motion'; import { PostCard } from '../PostCard'; import { EmptyState } from '@/shared/ui'; import type { FeedData } from '@/domains/post/model/types'; +import { + useEffectiveMotionMode, + type EffectiveMotionMode, +} from '@/shared/motion/model/motion-mode'; interface PostListProps { posts: FeedData[]; layout?: 'grid' | 'list'; } -const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, +function getContainerVariants( + effectiveMotionMode: EffectiveMotionMode +): Variants { + return { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: effectiveMotionMode === 'reduced' ? 0.03 : 0.08, + }, }, - }, -}; + }; +} + +function getItemVariants(effectiveMotionMode: EffectiveMotionMode): Variants { + const shouldTranslate = effectiveMotionMode === 'full'; -const itemVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - type: 'spring', - stiffness: 300, - damping: 30, + return { + hidden: { + opacity: 0, + y: shouldTranslate ? 20 : 0, }, - }, -}; + visible: { + opacity: 1, + y: 0, + transition: { + duration: effectiveMotionMode === 'reduced' ? 0.16 : 0.28, + ease: [0.22, 1, 0.36, 1], + }, + }, + }; +} + +const layoutClassNames = { + grid: 'grid gap-6 md:grid-cols-2', + list: 'space-y-4', +} satisfies Record, string>; export default function PostList({ posts, layout = 'grid' }: PostListProps) { + const effectiveMotionMode = useEffectiveMotionMode(); + if (posts.length === 0) { return ( + {posts.map((post) => ( +
+ +
+ ))} + + ); + } + + const containerVariants = getContainerVariants(effectiveMotionMode); + const itemVariants = getItemVariants(effectiveMotionMode); + if (layout === 'list') { return ( {posts.map((post) => ( @@ -66,7 +106,7 @@ export default function PostList({ posts, layout = 'grid' }: PostListProps) { variants={containerVariants} initial="hidden" animate="visible" - className="grid gap-6 md:grid-cols-2" + className={layoutClassNames.grid} > {posts.map((post) => ( diff --git a/src/features/blog/ui/components/ReadingProgress/ReadingProgress.tsx b/src/features/blog/ui/components/ReadingProgress/ReadingProgress.tsx index 5746a36..518177d 100644 --- a/src/features/blog/ui/components/ReadingProgress/ReadingProgress.tsx +++ b/src/features/blog/ui/components/ReadingProgress/ReadingProgress.tsx @@ -2,15 +2,25 @@ import { useEffect, useState } from 'react'; import { motion, useScroll, useSpring } from 'framer-motion'; +import { useEffectiveMotionMode } from '@/shared/motion/model/motion-mode'; export default function ReadingProgress() { const [isVisible, setIsVisible] = useState(false); + const effectiveMotionMode = useEffectiveMotionMode(); const { scrollYProgress } = useScroll(); - const scaleX = useSpring(scrollYProgress, { + const smoothScaleX = useSpring(scrollYProgress, { stiffness: 100, damping: 30, restDelta: 0.001, }); + const scaleX = + effectiveMotionMode === 'full' ? smoothScaleX : scrollYProgress; + const opacityTransitionDuration = + effectiveMotionMode === 'off' + ? 0 + : effectiveMotionMode === 'reduced' + ? 0.08 + : 0.2; useEffect(() => { const handleScroll = () => { @@ -26,7 +36,7 @@ export default function ReadingProgress() { className="fixed top-0 left-0 right-0 h-1 bg-[var(--color-grey-100)] z-[var(--z-sticky)] origin-left" initial={{ opacity: 0 }} animate={{ opacity: isVisible ? 1 : 0 }} - transition={{ duration: 0.2 }} + transition={{ duration: opacityTransitionDuration }} > ( #{tag} diff --git a/src/features/search/model/get-search-actions.test.ts b/src/features/search/model/get-search-actions.test.ts index 9e6cc5a..31ead52 100644 --- a/src/features/search/model/get-search-actions.test.ts +++ b/src/features/search/model/get-search-actions.test.ts @@ -24,11 +24,23 @@ describe('getSearchActions', () => { }, ]); - expect(actions).toHaveLength(1); - expect(actions[0].id).toBe('redis-basics'); - expect(actions[0].name).toBe('Redis Basics'); - expect(actions[0].section).toBe('블로그 포스트'); - expect(actions[0].keywords).toContain('Redis'); + const postAction = actions.find((action) => action.id === 'redis-basics'); + + expect(postAction).toBeDefined(); + expect(postAction?.name).toBe('Redis Basics'); + expect(postAction?.section).toBe('블로그 포스트'); + expect(postAction?.keywords).toContain('Redis'); + }); + + it('includes section navigation actions for scoped search', () => { + const actions = getSearchActions([]); + + expect(actions.map((action) => action.id)).toEqual([ + 'go-engineering', + 'go-life', + 'go-resume', + ]); + expect(actions[2].keywords).toContain('이력서'); }); it('handles missing tag arrays without crashing', () => { @@ -42,23 +54,39 @@ describe('getSearchActions', () => { }, ]); - expect(actions).toHaveLength(1); - expect(actions[0].keywords).toBe('No Tags Life Tags missing post'); + const postAction = actions.find((action) => action.id === 'no-tags'); + + expect(postAction?.keywords).toBe('No Tags Life Tags missing post'); }); it('keeps post list order as provided', () => { const actions = getSearchActions([ - { slug: 'second', title: 'B', category: 'Tech', tags: ['A'], description: 'B' }, - { slug: 'first', title: 'A', category: 'Tech', tags: ['A'], description: 'A' }, + { + slug: 'second', + title: 'B', + category: 'Tech', + tags: ['A'], + description: 'B', + }, + { + slug: 'first', + title: 'A', + category: 'Tech', + tags: ['A'], + description: 'A', + }, ]); - expect(actions.map((action) => action.id)).toEqual(['second', 'first']); + expect(actions.map((action) => action.id).slice(3)).toEqual([ + 'second', + 'first', + ]); }); it('tracks and navigates when action is performed', () => { mockTrackEvent.mockClear(); - const [action] = getSearchActions([ + const action = getSearchActions([ { slug: 'redis-basics', title: 'Redis Basics', @@ -66,15 +94,15 @@ describe('getSearchActions', () => { tags: ['Redis'], description: 'Redis 기초', }, - ]); + ]).find((candidate) => candidate.id === 'redis-basics'); - action.perform?.(); + action?.perform?.(); expect(mockTrackEvent).toHaveBeenCalledWith('click', { target: 'command_palette_result', post_slug: 'redis-basics', }); - expect(typeof action.perform).toBe('function'); - expect(() => action.perform?.()).not.toThrow(); + expect(typeof action?.perform).toBe('function'); + expect(() => action?.perform?.()).not.toThrow(); }); }); diff --git a/src/features/search/model/get-search-actions.ts b/src/features/search/model/get-search-actions.ts index 95f250a..f740e4b 100644 --- a/src/features/search/model/get-search-actions.ts +++ b/src/features/search/model/get-search-actions.ts @@ -7,6 +7,38 @@ type SearchablePost = Pick< 'slug' | 'title' | 'category' | 'tags' | 'description' >; +interface NavigationActionSource { + id: string; + name: string; + href: string; + keywords: string; + subtitle: string; +} + +const navigationActions: NavigationActionSource[] = [ + { + id: 'go-engineering', + name: 'Tech', + href: '/engineering', + keywords: 'Tech Engineering 기술 글 시리즈', + subtitle: '기술 글과 시리즈 보기', + }, + { + id: 'go-life', + name: 'Life', + href: '/life', + keywords: 'Life 회고 에세이 일상', + subtitle: '회고와 에세이 보기', + }, + { + id: 'go-resume', + name: 'Resume', + href: '/resume', + keywords: 'Resume 이력서 경력 프로젝트', + subtitle: '경력과 프로젝트 보기', + }, +]; + /** * 전역 검색을 위한 액션 초기 데이터 생성 함수 * 블로그 포스트를 검색할 수 있게 액션 객체 배열을 반환합니다. @@ -36,5 +68,21 @@ export const getSearchActions = (posts: SearchablePost[]): Action[] => { subtitle: post.description, })); - return postActions; + const sectionActions = navigationActions.map((action) => ({ + id: action.id, + name: action.name, + shortcut: [], + keywords: action.keywords, + section: '섹션', + perform: () => { + trackEvent(AnalyticsEvents.click, { + target: 'command_palette_section', + destination: action.href, + }); + window.location.assign(action.href); + }, + subtitle: action.subtitle, + })); + + return [...sectionActions, ...postActions]; }; diff --git a/src/features/search/model/search-recommendations.test.ts b/src/features/search/model/search-recommendations.test.ts new file mode 100644 index 0000000..6c0a7d5 --- /dev/null +++ b/src/features/search/model/search-recommendations.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { getRecommendedSearchTerms } from './search-recommendations'; + +describe('getRecommendedSearchTerms', () => { + it('returns popular tags ordered by frequency', () => { + const terms = getRecommendedSearchTerms([ + { tags: ['Redis', '회고'] }, + { tags: ['Redis', 'Flink'] }, + { tags: ['회고'] }, + ]); + + expect(terms.slice(0, 3)).toEqual(['Redis', '회고', 'Flink']); + }); + + it('falls back to section terms when posts do not have tags', () => { + expect(getRecommendedSearchTerms([{ tags: [] }])).toEqual([ + 'Tech', + 'Life', + 'Resume', + ]); + }); + + it('trims empty tags and respects the requested limit', () => { + expect( + getRecommendedSearchTerms([{ tags: [' Redis ', '', 'Flink', '회고'] }], 2) + ).toHaveLength(2); + }); +}); diff --git a/src/features/search/model/search-recommendations.ts b/src/features/search/model/search-recommendations.ts new file mode 100644 index 0000000..cdac057 --- /dev/null +++ b/src/features/search/model/search-recommendations.ts @@ -0,0 +1,49 @@ +import type { FeedData } from '@/domains/post/model/types'; + +type SearchRecommendationSource = Pick; + +const FALLBACK_RECOMMENDATIONS = ['Tech', 'Life', 'Resume']; + +export function getRecommendedSearchTerms( + posts: SearchRecommendationSource[], + limit = 5 +): string[] { + const counts = new Map(); + const firstSeenIndexes = new Map(); + let nextIndex = 0; + + posts.forEach((post) => { + post.tags?.forEach((tag) => { + const term = tag.trim(); + + if (term.length === 0) { + return; + } + + if (!firstSeenIndexes.has(term)) { + firstSeenIndexes.set(term, nextIndex); + nextIndex += 1; + } + + counts.set(term, (counts.get(term) ?? 0) + 1); + }); + }); + + const recommendations = Array.from(counts.entries()) + .sort(([leftTerm, leftCount], [rightTerm, rightCount]) => { + if (leftCount !== rightCount) { + return rightCount - leftCount; + } + + return ( + (firstSeenIndexes.get(leftTerm) ?? 0) - + (firstSeenIndexes.get(rightTerm) ?? 0) + ); + }) + .map(([term]) => term) + .slice(0, limit); + + return recommendations.length > 0 + ? recommendations + : FALLBACK_RECOMMENDATIONS.slice(0, limit); +} diff --git a/src/features/search/ui/components/CommandPalette/CommandPalette.module.css b/src/features/search/ui/components/CommandPalette/CommandPalette.module.css index 5270c10..6edb2dc 100644 --- a/src/features/search/ui/components/CommandPalette/CommandPalette.module.css +++ b/src/features/search/ui/components/CommandPalette/CommandPalette.module.css @@ -60,8 +60,8 @@ } .closeButton { - width: 1.875rem; - height: 1.875rem; + width: 2.75rem; + height: 2.75rem; border-radius: var(--radius-full); background: color-mix( in srgb, @@ -81,7 +81,67 @@ border-color: var(--color-toss-blue); color: var(--color-toss-blue); background: var(--color-bg-primary); - outline: none; + outline: 2px solid var(--color-toss-blue); + outline-offset: 2px; +} + +.scopeBar { + display: flex; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + overflow-x: auto; + border-bottom: 1px solid var(--color-border); +} + +.scopeButton { + min-height: 2.5rem; + padding: 0 var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + background: var(--color-bg-primary); + color: var(--color-text-secondary); + font-size: var(--text-sm); + font-weight: var(--font-medium); + white-space: nowrap; + cursor: pointer; + transition: + background-color var(--duration-150) var(--ease-default), + border-color var(--duration-150) var(--ease-default), + color var(--duration-150) var(--ease-default); +} + +.scopeButton:hover, +.scopeButton:focus-visible { + border-color: var(--color-toss-blue); + color: var(--color-toss-blue); + outline: 2px solid var(--color-toss-blue); + outline-offset: 2px; +} + +.scopeButtonActive { + border-color: var(--color-toss-blue); + background: var(--color-toss-blue); + color: white; +} + +.scopeButtonActive:hover, +.scopeButtonActive:focus-visible { + color: white; +} + +.recommendationPanel { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); +} + +.recommendationLabel { + flex-shrink: 0; + font-size: var(--text-xs); + font-weight: var(--font-semibold); + color: var(--color-text-tertiary); } .results { @@ -111,6 +171,7 @@ .resultItem { padding: var(--space-3) var(--space-5); + min-height: 3.25rem; display: flex; align-items: center; justify-content: space-between; @@ -181,13 +242,14 @@ } .suggestionButton { + min-height: 2.75rem; border: 1px solid var(--color-border); background: var(--color-bg-secondary); color: var(--color-text-primary); font-size: var(--text-sm); font-weight: var(--font-medium); border-radius: var(--radius-full); - padding: var(--space-1) var(--space-3); + padding: 0 var(--space-4); cursor: pointer; transition: all var(--duration-150) var(--ease-default); } @@ -196,7 +258,8 @@ .suggestionButton:focus-visible { border-color: var(--color-toss-blue); color: var(--color-toss-blue); - outline: none; + outline: 2px solid var(--color-toss-blue); + outline-offset: 2px; } .recoveryActions { @@ -206,9 +269,13 @@ } .recoveryButton { + min-height: 2.75rem; + display: inline-flex; + align-items: center; + justify-content: center; border: 1px solid var(--color-border); border-radius: var(--radius-sm); - padding: var(--space-1) var(--space-3); + padding: 0 var(--space-4); background: transparent; color: var(--color-text-secondary); font-size: var(--text-sm); @@ -221,7 +288,8 @@ .recoveryButton:focus-visible { border-color: var(--color-toss-blue); color: var(--color-toss-blue); - outline: none; + outline: 2px solid var(--color-toss-blue); + outline-offset: 2px; } @media (max-width: 767px) { @@ -243,6 +311,21 @@ padding: var(--space-3); } + .scopeBar { + padding: var(--space-3); + } + + .scopeButton { + min-height: 2.75rem; + } + + .recommendationPanel { + align-items: flex-start; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + } + .search { font-size: var(--text-md); } diff --git a/src/features/search/ui/components/CommandPalette/CommandPalette.tsx b/src/features/search/ui/components/CommandPalette/CommandPalette.tsx index 2cfacb7..3c81330 100644 --- a/src/features/search/ui/components/CommandPalette/CommandPalette.tsx +++ b/src/features/search/ui/components/CommandPalette/CommandPalette.tsx @@ -11,10 +11,47 @@ import { useMatches, } from 'kbar'; import styles from './CommandPalette.module.css'; +import type { FeedData } from '@/domains/post/model/types'; import { AnalyticsEvents, trackEvent } from '@/shared/analytics/lib/analytics'; +import { getRecommendedSearchTerms } from '@/features/search/model/search-recommendations'; -export const CommandPalette = () => { - const { query } = useKBar(); +type SearchScopeId = 'all' | 'tech' | 'life' | 'resume'; + +interface SearchScope { + id: SearchScopeId; + label: string; + query: string; +} + +interface CommandPaletteProps { + posts?: FeedData[]; +} + +const SEARCH_SCOPES: SearchScope[] = [ + { id: 'all', label: '전체', query: '' }, + { id: 'tech', label: 'Tech', query: 'Tech' }, + { id: 'life', label: 'Life', query: 'Life' }, + { id: 'resume', label: 'Resume', query: 'Resume' }, +]; + +function getActiveSearchScope(normalizedQuery: string): SearchScopeId { + const matchedScope = SEARCH_SCOPES.find( + (scope) => scope.query.toLowerCase() === normalizedQuery.toLowerCase() + ); + + return matchedScope?.id ?? 'all'; +} + +export const CommandPalette = ({ posts = [] }: CommandPaletteProps) => { + const { query, searchQuery } = useKBar((state) => ({ + searchQuery: state.searchQuery, + })); + const normalizedQuery = searchQuery.trim(); + const activeScope = getActiveSearchScope(normalizedQuery); + const recommendedTerms = React.useMemo( + () => getRecommendedSearchTerms(posts), + [posts] + ); return ( @@ -23,8 +60,8 @@ export const CommandPalette = () => {
- + query.setSearch(scope.query)} + /> + {normalizedQuery.length === 0 && ( +
+

추천 키워드

+ +
+ )} +
); }; -function RenderResults() { +function SearchScopeBar({ + activeScope, + onSelect, +}: { + activeScope: SearchScopeId; + onSelect: (scope: SearchScope) => void; +}) { + return ( +
+ {SEARCH_SCOPES.map((scope) => { + const isActive = scope.id === activeScope; + + return ( + + ); + })} +
+ ); +} + +function SearchSuggestionButtons({ + terms, + onSelect, +}: { + terms: string[]; + onSelect: (term: string) => void; +}) { + return ( +
+ {terms.map((keyword) => ( + + ))} +
+ ); +} + +function RenderResults({ recommendedTerms }: { recommendedTerms: string[] }) { const { results } = useMatches(); const { query, searchQuery } = useKBar((state) => ({ searchQuery: state.searchQuery, @@ -101,18 +202,10 @@ function RenderResults() {

다른 키워드로 검색하거나 추천 키워드를 선택해 보세요.

-
- {['Redis', 'Flink', '회고'].map((keyword) => ( - - ))} -
+