Repository: kss761036/Epigram
Pull request #65: 62/Feat/카드_컴포넌트
Author: login: kss761036
Labels: name: Priority: Medium :bookmark:
description: 우선순위 보통
name: Type: Feature :sparkles:
description: 신규 기능
Created: 2025-03-14T05:06:30Z | Merged: 2025-03-14T05:33:43Z

## ❓이슈

- close #62

## :memo: Description

- 카드 컴포넌트 구현
- 검색결과 카드 컴포넌트 구현
- 검색결과 카드 컴포넌트는 띄어쓰기 포함으로 구현했습니다.

<!-- 어떤 내용의 PR인지 작성해 주세요. (ex. 메인 페이지 레이아웃 작업) -->
<!-- ⚠️ PR에는 해당 PR의 제목에 해당하는 내용만 들어가 있어야 합니다!  -->

## :cyclone: PR Type

어떤 변경 사항이 있나요?

<!-- 해당 사항에 체크해 주세요. -->

- [x] 새로운 기능 추가
- [ ] 버그 수정
- [ ] CSS 등 사용자 UI 디자인 변경
- [ ] 코드에 영향을 주지 않는 변경사항 (오타 수정, 탭 사이즈 변경, 변수명 변경)
- [ ] 코드 리팩토링
- [ ] 주석 추가 및 수정
- [ ] 문서 수정
- [x] 테스트 추가, 테스트 리팩토링
- [ ] 빌드 부분 혹은 패키지 매니저 수정
- [ ] 파일 혹은 폴더명 수정
- [ ] 파일 혹은 폴더 삭제

### PR

<!-- 작성중인 PR인 경우, Draft 모드로 생성해주세요. -->

- [x] Branch Convention 확인
  > `feat/*` 기능 구현, `fix/*` 버그 수정, `refactor/*` 개선
- [x] Base Branch 확인
- [x] 적절한 Label 지정
- [x] Assignee 및 Reviewer 지정


Linked issue #62: Feat: 카드 컴포넌트 작업
### ❓ 설명

카드 컴포넌트 작업

### ✅ 작업할 내용

- [ ] 카드 컴포넌트
- [ ] 카드 검색 컴포넌트


### 📄 참고 자료

_No response_

Diff:
diff --git a/public/notebook-bg.svg b/public/notebook-bg.svg
new file mode 100644
index 0000000..3074c15
--- /dev/null
+++ b/public/notebook-bg.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="24">
+  <line x1="0" y1="24" x2="100%" y2="24" stroke="#f2f2f2" stroke-width="1"/>
+</svg>
diff --git a/src/components/Card.stories.ts b/src/components/Card.stories.ts
new file mode 100644
index 0000000..7e4216e
--- /dev/null
+++ b/src/components/Card.stories.ts
@@ -0,0 +1,81 @@
+import { Meta, StoryObj } from '@storybook/react';
+import { action } from '@storybook/addon-actions';
+import Card from './Card';
+
+const meta: Meta<typeof Card> = {
+  component: Card,
+  title: 'Components/Card',
+  tags: ['autodocs'],
+  parameters: {
+    docs: {
+      description: {
+        component: '공용 Card 컴포넌트',
+      },
+    },
+  },
+  argTypes: {
+    content: {
+      description: '콘텐츠 내용',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+    author: {
+      description: '저자',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+    tags: {
+      description: '태그 목록',
+      table: {
+        type: { summary: 'array' },
+      },
+      control: { type: 'object' },
+    },
+    referenceUrl: {
+      description: '링크 URL',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+    className: {
+      description: '추가 className (카드 전체)',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+    contentClassName: {
+      description: '추가 contentClassName (카드 문구에 적용)',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+  },
+};
+
+export default meta;
+type Story = StoryObj<typeof Card>;
+
+export const Default: Story = {
+  args: {
+    content:
+      '이 세상에는 위대한 진실이 하나 있어. 무언가를 온 마음을 다해 원한다면, 반드시 그렇게 된다는 거야. 무언가를 바라는 마음은 곧 우주의 마음으로부터 비롯된 것이기 때문이지.',
+    author: '파울로 코엘료',
+    tags: [
+      { id: 1, name: '나아가야할때' },
+      { id: 2, name: '꿈을이루고싶을때' },
+    ],
+    referenceUrl: '/',
+    className: undefined,
+    contentClassName: undefined,
+  },
+  parameters: {
+    layout: 'centered',
+  },
+};
diff --git a/src/components/Card.test.tsx b/src/components/Card.test.tsx
new file mode 100644
index 0000000..039f4a1
--- /dev/null
+++ b/src/components/Card.test.tsx
@@ -0,0 +1,42 @@
+import { render, screen } from '@testing-library/react';
+import Card from './Card';
+
+describe('Card Component', () => {
+  const mockProps = {
+    content: '테스트 카드 내용이 길어서 말줄임표가 적용되는지 확인하는 테스트',
+    referenceUrl: '/test-url',
+    author: '테스트 작성자',
+    tags: [{ id: 1, name: '테스트태그' }],
+  };
+
+  test('카드 내용이 올바르게 렌더링 되는지 확인', () => {
+    render(<Card {...mockProps} />);
+    expect(
+      screen.getByText('테스트 카드 내용이 길어서 말줄임표가 적용되는지 확인하는 테스트', {
+        exact: false,
+      }),
+    ).toBeInTheDocument();
+  });
+
+  test('작성자가 존재하면 표시되는지 확인', () => {
+    render(<Card {...mockProps} />);
+    expect(screen.getByText('- 테스트 작성자 -')).toBeInTheDocument();
+  });
+
+  test('태그가 올바르게 렌더링 되는지 확인', () => {
+    render(<Card {...mockProps} />);
+    expect(screen.getByText('#테스트태그')).toBeInTheDocument();
+  });
+
+  test('내용이 지정된 줄 수 이상일 때 말줄임표 적용 확인', () => {
+    render(
+      <Card
+        {...mockProps}
+        content='이것은 매우 긴 카드 내용으로서 여러 줄에 걸쳐 렌더링될 것입니다. 이 테스트는 말줄임표 적용 여부를 확인하기 위함입니다.'
+      />,
+    );
+    const contentElement = screen.getByText(/이것은 매우 긴 카드 내용/, { exact: false });
+    expect(contentElement).toBeInTheDocument();
+    expect(contentElement).toHaveClass('line-clamp-3');
+  });
+});
diff --git a/src/components/Card.tsx b/src/components/Card.tsx
new file mode 100644
index 0000000..53e1279
--- /dev/null
+++ b/src/components/Card.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { cn } from '@/utils/helper';
+import Link from 'next/link';
+
+interface CardProps {
+  content: string;
+  referenceUrl: string;
+  author?: string;
+  tags: { id: number; name: string }[];
+  className?: string;
+  contentClassName?: string;
+}
+
+export default function Card({
+  content,
+  author,
+  tags,
+  className,
+  referenceUrl,
+  contentClassName,
+}: CardProps) {
+  const classes = {
+    cardWrapper: cn('font-iropke flex h-full flex-col text-left', className),
+    notebookBg: "bg-[url('/notebook-bg.svg')] bg-repeat bg-white",
+    cardBox:
+      'flex h-full flex-1 flex-col rounded-[16px] border border-[#f2f2f2] p-4 md:p-6 min-h-[120px] shadow-[0px_3px_12px_0px_rgba(0,0,0,0.04)] md:min-h-[140px] lg:min-h-[180px]',
+    cardContent: cn(
+      'mb-auto line-clamp-3 text-[12px] leading-relaxed break-keep md:line-clamp-4 md:text-[16px] lg:text-[24px]',
+      contentClassName,
+    ),
+    cardAuthor:
+      'mt-[6px] text-right text-[12px] leading-relaxed text-blue-400 md:mt-0 md:text-[16px] lg:mt-3 lg:text-[24px]',
+    cardTag:
+      'mt-2 flex flex-wrap justify-end gap-x-2 text-right text-[12px] leading-relaxed text-blue-400 md:text-[16px] lg:text-[24px]',
+  };
+
+  return (
+    <div className={classes.cardWrapper}>
+      <Link href={referenceUrl} className={`${classes.notebookBg} ${classes.cardBox}`}>
+        <div className={classes.cardContent}>{content}</div>
+        {author && <div className={classes.cardAuthor}>- {author} -</div>}
+      </Link>
+      {tags.length > 0 && (
+        <ul className={classes.cardTag}>
+          {tags.map((tag) => (
+            <li key={tag.id}>#{tag.name}</li>
+          ))}
+        </ul>
+      )}
+    </div>
+  );
+}
diff --git a/src/components/SearchCard.stories.ts b/src/components/SearchCard.stories.ts
new file mode 100644
index 0000000..0bcd2a5
--- /dev/null
+++ b/src/components/SearchCard.stories.ts
@@ -0,0 +1,123 @@
+import { Meta, StoryObj } from '@storybook/react';
+import { action } from '@storybook/addon-actions';
+import SearchCard from './SearchCard';
+
+const meta: Meta<typeof SearchCard> = {
+  component: SearchCard,
+  title: 'Components/SearchCard',
+  tags: ['autodocs'],
+  parameters: {
+    docs: {
+      description: {
+        component: '검색된 Card 결과 컴포넌트',
+      },
+    },
+  },
+  argTypes: {
+    keyword: {
+      description: '검색 키워드 (빈칸 포함 가능)',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+    content: {
+      description: '콘텐츠 내용',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+    author: {
+      description: '저자',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+    tags: {
+      description: '태그 목록',
+      table: {
+        type: { summary: 'array' },
+      },
+      control: { type: 'object' },
+    },
+    referenceUrl: {
+      description: '링크 URL',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+    className: {
+      description: '추가 className (카드 전체)',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+    contentClassName: {
+      description: '추가 contentClassName (카드 문구에 적용)',
+      table: {
+        type: { summary: 'string' },
+      },
+      control: 'text',
+    },
+  },
+};
+
+export default meta;
+type Story = StoryObj<typeof SearchCard>;
+
+export const Default: Story = {
+  args: {
+    keyword: '우울',
+    content: '우울증이란 우리를 내적인 나락으로 이끄는 유혹의 손길이다.',
+    author: '클라우스 랑에',
+    tags: [
+      { id: 1, name: '짧은명언' },
+      { id: 2, name: '우울증' },
+    ],
+    referenceUrl: '/',
+    className: undefined,
+    contentClassName: undefined,
+  },
+  parameters: {
+    layout: 'centered',
+  },
+};
+
+export const 검색어의_빈칸_존재: Story = {
+  args: {
+    keyword: '우 울',
+    content:
+      '이 세상에는 위대한 진실이 하나 있어. 무언가를 온 마음을 다해 원한다면, 반드시 그렇게 된다는 거야. ',
+    author: '파우울로 코엘료',
+    tags: [{ id: 1, name: '새로운영감' }],
+    referenceUrl: '/',
+    className: undefined,
+    contentClassName: undefined,
+  },
+  parameters: {
+    layout: 'centered',
+  },
+};
+
+export const 내용의_빈칸_포함: Story = {
+  args: {
+    keyword: '우울',
+    content: '오랫동안 꿈을 그리는 사람은 마침내 그 꿈을 닮아 간다.',
+    author: '앙드우 울로',
+    tags: [
+      { id: 1, name: '동기부여' },
+      { id: 2, name: '우울할때' },
+      { id: 3, name: '나아가야할때' },
+    ],
+    referenceUrl: '/',
+    className: undefined,
+    contentClassName: undefined,
+  },
+  parameters: {
+    layout: 'centered',
+  },
+};
diff --git a/src/components/SearchCard.test.tsx b/src/components/SearchCard.test.tsx
new file mode 100644
index 0000000..06d9257
--- /dev/null
+++ b/src/components/SearchCard.test.tsx
@@ -0,0 +1,40 @@
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import SearchCard from './SearchCard';
+
+describe('SearchCard Component', () => {
+  const mockProps = {
+    keyword: '우울',
+    content: '우울증이란 우리를 내적인 나락으로 이끄는 유혹의 손길이다.',
+    author: '클라우스 랑에',
+    tags: [
+      { id: 1, name: '짧은명언' },
+      { id: 2, name: '우울증' },
+    ],
+    referenceUrl: '/',
+    className: undefined,
+    contentClassName: undefined,
+  };
+
+  test('검색 키워드가 올바르게 표시되는지 확인', () => {
+    render(<SearchCard {...mockProps} />);
+    expect(screen.getAllByText(mockProps.keyword).length).toBeGreaterThan(0);
+  });
+
+  test('띄어쓰기가 있는 검색 키워드도 정규표현식으로 정상적으로 찾을 수 있는지 확인', () => {
+    const spacedProps = { ...mockProps, keyword: '우 울' };
+    render(<SearchCard {...spacedProps} />);
+    const regex = new RegExp(spacedProps.keyword.replace(/\s/g, ''), 'g');
+    expect(screen.getByRole('link').textContent?.replace(/\s+/g, '')).toMatch(regex);
+  });
+
+  test('문구 중 띄어쓰기가 포함된 검색어도 정규표현식으로 정상적으로 찾을 수 있는지 확인', () => {
+    const spacedContentProps = {
+      ...mockProps,
+      content: '우 울증이란 우리를 내적인 나락으로 이끄는 유혹의 손길이다.',
+    };
+    render(<SearchCard {...spacedContentProps} />);
+    const regex = new RegExp(spacedContentProps.content.replace(/\s/g, ''), 'g');
+    expect(screen.getByRole('link').textContent?.replace(/\s/g, '')).toMatch(regex);
+  });
+});
diff --git a/src/components/SearchCard.tsx b/src/components/SearchCard.tsx
new file mode 100644
index 0000000..a0fc55d
--- /dev/null
+++ b/src/components/SearchCard.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { cn } from '@/utils/helper';
+import Link from 'next/link';
+
+interface SearchCardProps {
+  content: string;
+  referenceUrl: string;
+  author?: string;
+  tags: { id: number; name: string }[];
+  className?: string;
+  contentClassName?: string;
+  keyword?: string;
+}
+
+export default function SearchCard({
+  content,
+  author,
+  tags,
+  className,
+  referenceUrl,
+  contentClassName,
+  keyword,
+}: SearchCardProps) {
+  // 키워드가 존재하면 공백을 제거하여 정규표현식 생성
+  keyword = keyword ? keyword.replace(/\s+/g, '') : '';
+
+  if (keyword) {
+    // 키워드를 공백으로 구분하여 정규표현식 생성
+    const regex = new RegExp(keyword.split('').join('\\s*'), 'gi');
+
+    content = content.replace(regex, `<span class="text-illust-blue">$&</span>`);
+    author = author?.replace(regex, `<span class="text-illust-blue">$&</span>`);
+    tags = tags?.map((tag) => ({
+      ...tag,
+      name: tag.name.replace(regex, `<span class="text-illust-blue">$&</span>`),
+    }));
+  }
+
+  const classes = {
+    cardWrapper: cn(
+      'block font-iropke bg-white text-left py-4 md:p-x-6 lg:py-6 border-b border-gray-100',
+      className,
+    ),
+    cardContent: cn('text-[16px] leading-relaxed break-keep lg:text-[20px]', contentClassName),
+    cardAuthor: 'mt-1 text-[16px] leading-relaxed text-blue-400 md:mt-2 lg:mt-6 lg:text-[20px]',
+    cardTag:
+      'mt-2 flex flex-wrap justify-end gap-x-2 text-right text-[16px] leading-relaxed text-blue-400 lg:gap-x-3 lg:mt-4 lg:text-[20px]',
+  };
+
+  return (
+    <Link href={referenceUrl} className={classes.cardWrapper}>
+      <div className={classes.cardContent} dangerouslySetInnerHTML={{ __html: content }} />
+      {author && (
+        <div className={classes.cardAuthor} dangerouslySetInnerHTML={{ __html: `- ${author} -` }} />
+      )}
+      {tags.length > 0 && (
+        <ul className={classes.cardTag}>
+          {tags.map((tag) => (
+            <li key={tag.id} dangerouslySetInnerHTML={{ __html: `#${tag.name}` }} />
+          ))}
+        </ul>
+      )}
+    </Link>
+  );
+}


Reviews:
user: login: cksrlcks
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-14T05:16:30Z
user: login: cksrlcks
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-14T05:17:30Z
user: login: kss761036
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-14T05:32:12Z
user: login: kss761036
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-14T05:32:14Z
user: login: cksrlcks
body: 수정해주셔서 감사합니다! 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-14T05:32:57Z

Review comments:
diff_hunk: @@ -0,0 +1,53 @@
+'use client';
+
+import { cn } from '@/utils/helper';
+import Link from 'next/link';
+
+interface CardProps {
+  content: string;
+  referenceUrl: string;
+  author?: string;
+  tags?: { id: number; name: string }[];
+  className?: string;
+  contentClassName?: string;
+}
+
+export default function Card({
+  content,
+  author,
+  tags,
+  className,
+  referenceUrl,
+  contentClassName,
+}: CardProps) {
+  const classes = {
+    cardWrapper: cn('font-iropke flex h-full flex-col text-left', className),
+    notebookBg: "bg-[url('/notebook-bg.svg')] bg-repeat bg-white",
+    cardBox:
+      'flex h-full flex-1 flex-col rounded-[16px] border border-[#f2f2f2] p-4 md:p-6 min-h-[120px] shadow-[0px_3px_12px_0px_rgba(0,0,0,0.04)] md:min-h-[140px] lg:min-h-[180px]',
+    cardContent: cn(
+      'mb-auto line-clamp-3 text-[12px] leading-relaxed break-keep md:line-clamp-4 md:text-[16px] lg:text-[24px]',
+      contentClassName,
+    ),
+    cardAuthor:
+      'mt-[6px] text-right text-[12px] leading-relaxed text-blue-400 md:mt-0 md:text-[16px] lg:mt-3 lg:text-[24px]',
+    cardTag:
+      'mt-2 flex flex-wrap justify-end gap-x-2 text-right text-[12px] leading-relaxed text-blue-400 md:text-[16px] lg:text-[24px]',
+  };
+
+  return (
+    <div className={classes.cardWrapper}>
+      <Link href={referenceUrl} className={`${classes.notebookBg} ${classes.cardBox}`}>
+        <div className={classes.cardContent}>{content}</div>
+        {author && <div className={classes.cardAuthor}>- {author} -</div>}
+      </Link>
+      {tags && (
+        <ul className={classes.cardTag}>
+          {tags.map((tag) => (
+            <li key={tag.id}>#{tag.name}</li>
+          ))}
+        </ul>
+      )}
path: src/components/Card.tsx
user: login: cksrlcks
body: <img width="1064" alt="image" src="https://github.com/user-attachments/assets/e7bc3074-d876-4d3f-8664-1c810424dd28" />

아마 tags가 빈배열일때도 ul이 랜더링이되서, ul이 가진 mt-2가 자리를 차지하고 있는것 같아용
created_at: 2025-03-14T05:16:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,66 @@
+'use client';
+
+import { cn } from '@/utils/helper';
+import Link from 'next/link';
+
+interface SearchCardProps {
+  content: string;
+  referenceUrl: string;
+  author?: string;
+  tags?: { id: number; name: string }[];
+  className?: string;
+  contentClassName?: string;
+  keyword?: string;
+}
+
+export default function SearchCard({
+  content,
+  author,
+  tags,
+  className,
+  referenceUrl,
+  contentClassName,
+  keyword,
+}: SearchCardProps) {
+  // 키워드가 존재하면 공백을 제거하여 정규표현식 생성
+  keyword = keyword ? keyword.replace(/\s+/g, '') : '';
+
+  if (keyword) {
+    // 키워드를 공백으로 구분하여 정규표현식 생성
+    const regex = new RegExp(keyword.split('').join('\\s*'), 'gi');
+
+    content = content.replace(regex, `<span class="text-illust-blue">$&</span>`);
+    author = author?.replace(regex, `<span class="text-illust-blue">$&</span>`);
+    tags = tags?.map((tag) => ({
+      ...tag,
+      name: tag.name.replace(regex, `<span class="text-illust-blue">$&</span>`),
+    }));
+  }
+
+  const classes = {
+    cardWrapper: cn(
+      'block font-iropke bg-white text-left py-4 md:p-x-6 lg:py-6 border-b border-gray-100',
+      className,
+    ),
+    cardContent: cn('text-[16px] leading-relaxed break-keep lg:text-[20px]', contentClassName),
+    cardAuthor: 'mt-1 text-[16px] leading-relaxed text-blue-400 md:mt-2 lg:mt-6 lg:text-[20px]',
+    cardTag:
+      'mt-2 flex flex-wrap justify-end gap-x-2 text-right text-[16px] leading-relaxed text-blue-400 lg:gap-x-3 lg:mt-4 lg:text-[20px]',
+  };
+
+  return (
+    <Link href={referenceUrl} className={classes.cardWrapper}>
+      <div className={classes.cardContent} dangerouslySetInnerHTML={{ __html: content }} />
+      {author && (
+        <div className={classes.cardAuthor} dangerouslySetInnerHTML={{ __html: `- ${author} -` }} />
+      )}
+      {tags && (
+        <ul className={classes.cardTag}>
+          {tags.map((tag) => (
+            <li key={tag.id} dangerouslySetInnerHTML={{ __html: `#${tag.name}` }} />
+          ))}
+        </ul>
path: src/components/SearchCard.tsx
user: login: cksrlcks
body: 여기도 Card에 써둔 코멘트랑 똑같이, tags 배열이 0보다큰지 체크해야할 것 같아욤
created_at: 2025-03-14T05:17:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,53 @@
+'use client';
+
+import { cn } from '@/utils/helper';
+import Link from 'next/link';
+
+interface CardProps {
+  content: string;
+  referenceUrl: string;
+  author?: string;
+  tags?: { id: number; name: string }[];
+  className?: string;
+  contentClassName?: string;
+}
+
+export default function Card({
+  content,
+  author,
+  tags,
+  className,
+  referenceUrl,
+  contentClassName,
+}: CardProps) {
+  const classes = {
+    cardWrapper: cn('font-iropke flex h-full flex-col text-left', className),
+    notebookBg: "bg-[url('/notebook-bg.svg')] bg-repeat bg-white",
+    cardBox:
+      'flex h-full flex-1 flex-col rounded-[16px] border border-[#f2f2f2] p-4 md:p-6 min-h-[120px] shadow-[0px_3px_12px_0px_rgba(0,0,0,0.04)] md:min-h-[140px] lg:min-h-[180px]',
+    cardContent: cn(
+      'mb-auto line-clamp-3 text-[12px] leading-relaxed break-keep md:line-clamp-4 md:text-[16px] lg:text-[24px]',
+      contentClassName,
+    ),
+    cardAuthor:
+      'mt-[6px] text-right text-[12px] leading-relaxed text-blue-400 md:mt-0 md:text-[16px] lg:mt-3 lg:text-[24px]',
+    cardTag:
+      'mt-2 flex flex-wrap justify-end gap-x-2 text-right text-[12px] leading-relaxed text-blue-400 md:text-[16px] lg:text-[24px]',
+  };
+
+  return (
+    <div className={classes.cardWrapper}>
+      <Link href={referenceUrl} className={`${classes.notebookBg} ${classes.cardBox}`}>
+        <div className={classes.cardContent}>{content}</div>
+        {author && <div className={classes.cardAuthor}>- {author} -</div>}
+      </Link>
+      {tags && (
+        <ul className={classes.cardTag}>
+          {tags.map((tag) => (
+            <li key={tag.id}>#{tag.name}</li>
+          ))}
+        </ul>
+      )}
path: src/components/Card.tsx
user: login: kss761036
body: 오 그러네요! 감사합니다
created_at: 2025-03-14T05:32:12Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,66 @@
+'use client';
+
+import { cn } from '@/utils/helper';
+import Link from 'next/link';
+
+interface SearchCardProps {
+  content: string;
+  referenceUrl: string;
+  author?: string;
+  tags?: { id: number; name: string }[];
+  className?: string;
+  contentClassName?: string;
+  keyword?: string;
+}
+
+export default function SearchCard({
+  content,
+  author,
+  tags,
+  className,
+  referenceUrl,
+  contentClassName,
+  keyword,
+}: SearchCardProps) {
+  // 키워드가 존재하면 공백을 제거하여 정규표현식 생성
+  keyword = keyword ? keyword.replace(/\s+/g, '') : '';
+
+  if (keyword) {
+    // 키워드를 공백으로 구분하여 정규표현식 생성
+    const regex = new RegExp(keyword.split('').join('\\s*'), 'gi');
+
+    content = content.replace(regex, `<span class="text-illust-blue">$&</span>`);
+    author = author?.replace(regex, `<span class="text-illust-blue">$&</span>`);
+    tags = tags?.map((tag) => ({
+      ...tag,
+      name: tag.name.replace(regex, `<span class="text-illust-blue">$&</span>`),
+    }));
+  }
+
+  const classes = {
+    cardWrapper: cn(
+      'block font-iropke bg-white text-left py-4 md:p-x-6 lg:py-6 border-b border-gray-100',
+      className,
+    ),
+    cardContent: cn('text-[16px] leading-relaxed break-keep lg:text-[20px]', contentClassName),
+    cardAuthor: 'mt-1 text-[16px] leading-relaxed text-blue-400 md:mt-2 lg:mt-6 lg:text-[20px]',
+    cardTag:
+      'mt-2 flex flex-wrap justify-end gap-x-2 text-right text-[16px] leading-relaxed text-blue-400 lg:gap-x-3 lg:mt-4 lg:text-[20px]',
+  };
+
+  return (
+    <Link href={referenceUrl} className={classes.cardWrapper}>
+      <div className={classes.cardContent} dangerouslySetInnerHTML={{ __html: content }} />
+      {author && (
+        <div className={classes.cardAuthor} dangerouslySetInnerHTML={{ __html: `- ${author} -` }} />
+      )}
+      {tags && (
+        <ul className={classes.cardTag}>
+          {tags.map((tag) => (
+            <li key={tag.id} dangerouslySetInnerHTML={{ __html: `#${tag.name}` }} />
+          ))}
+        </ul>
path: src/components/SearchCard.tsx
user: login: kss761036
body: 동일한 방법으로 수정했습니다!
created_at: 2025-03-14T05:32:14Z
author_association: OWNER

PR comments:
user: login: vercel[bot]
created_at: 2025-03-14T05:06:40Z
body: [vc]: #apOAz9FDDhIZvphe9yT/x2g1a17YNvtrxlJjMZdw1dg=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJlcGlncmFtLWdpbHYiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiZXBpZ3JhbS1naWx2LWdpdC1mZWF0LTYyLWtpbXNldW5nc2Vva3MtcHJvamVjdHMudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20va2ltc2V1bmdzZW9rcy1wcm9qZWN0cy9lcGlncmFtLWdpbHYvNk1uZ0c1S1BaUDhWbTE1Q25pcjZIeFp5QXh6SCIsInByZXZpZXdVcmwiOiJlcGlncmFtLWdpbHYtZ2l0LWZlYXQtNjIta2ltc2V1bmdzZW9rcy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **epigram-gilv** | ✅ Ready ([Inspect](https://vercel.com/kimseungseoks-projects/epigram-gilv/6MngG5KPZP8Vm15Cnir6HxZyAxzH)) | [Visit Preview](https://epigram-gilv-git-feat-62-kimseungseoks-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/epigram-gilv-git-feat-62-kimseungseoks-projects.vercel.app?via=pr-comment-feedback-link) | Mar 14, 2025 5:31am |


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-14T05:08:36Z
body: Storybook URL: https://storybook-static-ij1mfkzc6-cksrlcks-projects.vercel.app
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-14T05:32:50Z
body: Storybook URL: https://storybook-static-5wjucf4wy-cksrlcks-projects.vercel.app
author_association: NONE