Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 61 additions & 21 deletions src/features/blog/ui/components/PostList/PostList.tsx
Original file line number Diff line number Diff line change
@@ -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<NonNullable<PostListProps['layout']>, string>;

export default function PostList({ posts, layout = 'grid' }: PostListProps) {
const effectiveMotionMode = useEffectiveMotionMode();

if (posts.length === 0) {
return (
<EmptyState
Expand All @@ -44,13 +66,31 @@ export default function PostList({ posts, layout = 'grid' }: PostListProps) {
);
}

if (effectiveMotionMode === 'off') {
return (
<div className={layoutClassNames[layout]}>
{posts.map((post) => (
<div key={post.slug} className={layout === 'grid' ? 'h-full' : ''}>
<PostCard
post={post}
variant={layout === 'list' ? 'list' : 'default'}
/>
</div>
))}
</div>
);
}

const containerVariants = getContainerVariants(effectiveMotionMode);
const itemVariants = getItemVariants(effectiveMotionMode);

if (layout === 'list') {
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-4"
className={layoutClassNames.list}
>
{posts.map((post) => (
<motion.div key={post.slug} variants={itemVariants}>
Expand All @@ -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) => (
<motion.div key={post.slug} variants={itemVariants} className="h-full">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -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 }}
>
<motion.div
className="h-full bg-[var(--color-toss-blue)] origin-left"
Expand Down
2 changes: 1 addition & 1 deletion src/features/blog/ui/pages/BlogPostPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export default async function BlogPostPage({
{post.tags.map((tag) => (
<span
key={tag}
className="text-xs text-[var(--color-grey-500)] bg-[var(--color-grey-100)] px-2 py-1 rounded"
className="rounded-full bg-[var(--color-grey-100)] px-2.5 py-1.5 text-xs font-medium text-[var(--color-grey-500)]"
>
#{tag}
</span>
Expand Down
58 changes: 43 additions & 15 deletions src/features/search/model/get-search-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -42,39 +54,55 @@ 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',
category: 'Tech',
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();
});
});
50 changes: 49 additions & 1 deletion src/features/search/model/get-search-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '경력과 프로젝트 보기',
},
];

/**
* 전역 검색을 위한 액션 초기 데이터 생성 함수
* 블로그 포스트를 검색할 수 있게 액션 객체 배열을 반환합니다.
Expand Down Expand Up @@ -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];
};
28 changes: 28 additions & 0 deletions src/features/search/model/search-recommendations.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading