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
7 changes: 7 additions & 0 deletions src/pages/feed/Feed.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,10 @@ export const Container = styled.div`
background-color: var(--color-black-main);
margin: 0 auto;
`;

export const SkeletonWrapper = styled.div`
min-height: 100vh;
padding-top: 136px;
padding-bottom: 125px;
background-color: var(--color-black-main);
`;
16 changes: 11 additions & 5 deletions src/pages/feed/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import TabBar from '../../components/feed/TabBar';
import MyFeed from '../../components/feed/MyFeed';
import TotalFeed from '../../components/feed/TotalFeed';
import MainHeader from '@/components/common/MainHeader';
import LoadingSpinner from '../../components/common/LoadingSpinner';
import FeedPostSkeleton from '@/shared/ui/Skeleton/FeedPostSkeleton';
import writefab from '../../assets/common/writefab.svg';
import { useNavigate, useLocation } from 'react-router-dom';
import { getTotalFeeds } from '@/api/feeds/getTotalFeed';
import { getMyFeeds } from '@/api/feeds/getMyFeed';
import { useSocialLoginToken } from '@/hooks/useSocialLoginToken';
import { Container } from './Feed.styled';
import { Container, SkeletonWrapper } from './Feed.styled';
import type { PostData } from '@/types/post';

const tabs = ['피드', '내 피드'];
Expand Down Expand Up @@ -143,10 +143,12 @@ const Feed = () => {
setTabLoading(true);

try {
const minLoadingTime = new Promise(resolve => setTimeout(resolve, 1000));

if (activeTab === '피드') {
await loadTotalFeeds();
await Promise.all([loadTotalFeeds(), minLoadingTime]);
} else if (activeTab === '내 피드') {
await loadMyFeeds();
await Promise.all([loadMyFeeds(), minLoadingTime]);
}
} finally {
setTabLoading(false);
Expand All @@ -166,7 +168,11 @@ const Feed = () => {
/>
<TabBar tabs={tabs} activeTab={activeTab} onTabClick={setActiveTab} />
{initialLoading || tabLoading ? (
<LoadingSpinner size="large" fullHeight={true} />
<SkeletonWrapper>
{Array.from({ length: 3 }).map((_, index) => (
<FeedPostSkeleton key={index} />
))}
</SkeletonWrapper>
) : (
<>
{activeTab === '피드' ? (
Expand Down
34 changes: 34 additions & 0 deletions src/shared/ui/Skeleton/FeedPostSkeleton.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import styled from '@emotion/styled';
import Skeleton from './Skeleton';

export const Container = styled.div`
display: flex;
flex-direction: column;
width: 100%;
margin: 0 auto;
padding: 40px 20px;
gap: 16px;
background-color: var(--color-black-main);
`;

export const Header = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;

export const ContentWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;

export const ImageSkeleton = styled(Skeleton.Box)`
margin-top: 8px;
`;

export const Footer = styled.div`
display: flex;
gap: 16px;
margin-top: 8px;
`;
35 changes: 35 additions & 0 deletions src/shared/ui/Skeleton/FeedPostSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Skeleton from './Skeleton';
import {
Container,
Header,
ContentWrapper,
ImageSkeleton,
Footer,
} from './FeedPostSkeleton.styled';

/**
* FeedPost 스켈레톤 컴포넌트 예시
* 피드 포스트 로딩 시 사용
*/
const FeedPostSkeleton = () => {
return (
<Container>
<Header>
<Skeleton.Circle width={40} />
<Skeleton.Text width="120px" height={16} />
</Header>

<ContentWrapper>
<Skeleton.Text lines={3} height={16} gap={8} />
<ImageSkeleton width="100%" height="200px" borderRadius={8} />
</ContentWrapper>

<Footer>
<Skeleton.Box width={60} height={20} />
<Skeleton.Box width={60} height={20} />
</Footer>
</Container>
);
};

export default FeedPostSkeleton;
156 changes: 156 additions & 0 deletions src/shared/ui/Skeleton/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# Skeleton UI 컴포넌트

로딩 상태를 나타내는 스켈레톤 UI 컴포넌트입니다. Shimmer 애니메이션을 포함하며, 다양한 형태로 조합하여 사용할 수 있습니다.

## 기본 사용법

```tsx
import Skeleton from '@/shared/ui/Skeleton';

// 기본 스켈레톤
<Skeleton width="100%" height={20} />

// 박스 형태
<Skeleton.Box width={200} height={100} borderRadius={8} />

// 원형 (프로필 이미지 등)
<Skeleton.Circle width={40} />

// 텍스트 라인
<Skeleton.Text lines={3} height={16} gap={8} />
```

## API

### Skeleton (기본)
| Props | Type | Default | Description |
|-------|------|---------|-------------|
| width | string \| number | '100%' | 너비 (px 또는 문자열) |
| height | string \| number | 20 | 높이 (px 또는 문자열) |
| borderRadius | string \| number | 4 | 테두리 반경 (px 또는 문자열) |
| className | string | - | 추가 스타일을 위한 클래스명 |

### Skeleton.Box
기본 Skeleton과 동일한 props 사용

### Skeleton.Circle
| Props | Type | Default | Description |
|-------|------|---------|-------------|
| width | string \| number | 40 | 원의 크기 (width, height 동일) |
| className | string | - | 추가 스타일을 위한 클래스명 |

### Skeleton.Text
| Props | Type | Default | Description |
|-------|------|---------|-------------|
| lines | number | 1 | 텍스트 라인 개수 |
| gap | string \| number | 8 | 라인 사이 간격 (px 또는 문자열) |
| width | string \| number | '100%' | 너비 |
| height | string \| number | 16 | 각 라인의 높이 |
| lastLineWidth | string \| number | '70%' | 마지막 라인의 너비 |
| className | string | - | 추가 스타일을 위한 클래스명 |
Comment on lines +25 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

테이블 앞뒤에 빈 줄을 추가해야 합니다 (MD058).

정적 분석 도구에서 26번, 37번, 43번 줄의 Markdown 테이블이 빈 줄로 둘러싸이지 않았다고 경고합니다.

📝 수정 예시
 ### Skeleton (기본)
+
 | Props | Type | Default | Description |
 |-------|------|---------|-------------|
 ...
+
 ### Skeleton.Box

 ...

 ### Skeleton.Circle
+
 | Props | Type | Default | Description |
 ...
+
 ### Skeleton.Text
+
 | Props | Type | Default | Description |
 ...
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Skeleton (기본)
| Props | Type | Default | Description |
|-------|------|---------|-------------|
| width | string \| number | '100%' | 너비 (px 또는 문자열) |
| height | string \| number | 20 | 높이 (px 또는 문자열) |
| borderRadius | string \| number | 4 | 테두리 반경 (px 또는 문자열) |
| className | string | - | 추가 스타일을 위한 클래스명 |
### Skeleton.Box
기본 Skeleton과 동일한 props 사용
### Skeleton.Circle
| Props | Type | Default | Description |
|-------|------|---------|-------------|
| width | string \| number | 40 | 원의 크기 (width, height 동일) |
| className | string | - | 추가 스타일을 위한 클래스명 |
### Skeleton.Text
| Props | Type | Default | Description |
|-------|------|---------|-------------|
| lines | number | 1 | 텍스트 라인 개수 |
| gap | string \| number | 8 | 라인 사이 간격 (px 또는 문자열) |
| width | string \| number | '100%' | 너비 |
| height | string \| number | 16 | 각 라인의 높이 |
| lastLineWidth | string \| number | '70%' | 마지막 라인의 너비 |
| className | string | - | 추가 스타일을 위한 클래스명 |
### Skeleton (기본)
| Props | Type | Default | Description |
|-------|------|---------|-------------|
| width | string \| number | '100%' | 너비 (px 또는 문자열) |
| height | string \| number | 20 | 높이 (px 또는 문자열) |
| borderRadius | string \| number | 4 | 테두리 반경 (px 또는 문자열) |
| className | string | - | 추가 스타일을 위한 클래스명 |
### Skeleton.Box
기본 Skeleton과 동일한 props 사용
### Skeleton.Circle
| Props | Type | Default | Description |
|-------|------|---------|-------------|
| width | string \| number | 40 | 원의 크기 (width, height 동일) |
| className | string | - | 추가 스타일을 위한 클래스명 |
### Skeleton.Text
| Props | Type | Default | Description |
|-------|------|---------|-------------|
| lines | number | 1 | 텍스트 라인 개수 |
| gap | string \| number | 8 | 라인 사이 간격 (px 또는 문자열) |
| width | string \| number | '100%' | 너비 |
| height | string \| number | 16 | 각 라인의 높이 |
| lastLineWidth | string \| number | '70%' | 마지막 라인의 너비 |
| className | string | - | 추가 스타일을 위한 클래스명 |
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 26-26: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)


[warning] 37-37: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)


[warning] 43-43: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/Skeleton/README.md` around lines 25 - 50, Add a blank line
before and after each Markdown table so they are properly fenced by empty lines
to satisfy MD058: specifically insert an empty line above and below the table
under the "### Skeleton (기본)" heading, the table following "### Skeleton.Circle"
(the Circle props table), and the table under "### Skeleton.Text" so each table
is separated from surrounding paragraphs/headings by a blank line.


## 사용 예시

### 1. 피드 포스트 스켈레톤

```tsx
import Skeleton from '@/shared/ui/Skeleton';

const FeedPostSkeleton = () => {
return (
<div className="post-container">
{/* 헤더: 프로필 이미지 + 사용자 이름 */}
<div className="header">
<Skeleton.Circle width={40} />
<Skeleton.Text width="120px" height={16} />
</div>

{/* 본문 */}
<Skeleton.Text lines={3} height={16} gap={8} />

{/* 이미지 */}
<Skeleton.Box width="100%" height="200px" borderRadius={8} />

{/* 푸터 */}
<div className="footer">
<Skeleton.Box width={60} height={20} />
<Skeleton.Box width={60} height={20} />
</div>
</div>
);
};
```

### 2. 리스트 아이템 스켈레톤

```tsx
const ListItemSkeleton = () => {
return (
<div className="list-item">
<Skeleton.Circle width={56} />
<div className="content">
<Skeleton.Text width="80%" height={18} />
<Skeleton.Text width="60%" height={14} />
</div>
</div>
);
};
```

### 3. 카드 스켈레톤

```tsx
const CardSkeleton = () => {
return (
<div className="card">
<Skeleton.Box width="100%" height="150px" borderRadius={8} />
<Skeleton.Text lines={2} height={16} gap={8} />
<Skeleton.Box width="100px" height={32} borderRadius={16} />
</div>
);
};
```

### 4. 여러 개의 스켈레톤 표시

```tsx
const FeedListSkeleton = () => {
return (
<>
{Array.from({ length: 5 }).map((_, index) => (
<FeedPostSkeleton key={index} />
))}
</>
);
};
```

## 스타일 커스터마이징

styled-components를 사용하여 추가 스타일을 적용할 수 있습니다.

```tsx
import styled from '@emotion/styled';
import Skeleton from '@/shared/ui/Skeleton';

const CustomSkeleton = styled(Skeleton.Box)`
margin: 20px 0;
opacity: 0.6;
`;

// 사용
<CustomSkeleton width="100%" height={100} />
```

## 애니메이션

스켈레톤 컴포넌트는 자동으로 shimmer 애니메이션을 포함합니다.
- 애니메이션 지속 시간: 1.5초
- 부드러운 그라데이션 효과
- 다크 테마에 최적화된 색상

## 주의사항

- width, height에 숫자를 전달하면 자동으로 'px' 단위가 적용됩니다.
- 문자열 단위('%', 'rem', 'em' 등)를 사용하려면 문자열로 전달하세요.
- 예: `width={100}` → '100px', `width="100%"` → '100%'
57 changes: 57 additions & 0 deletions src/shared/ui/Skeleton/Skeleton.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import styled from '@emotion/styled';
import { keyframes } from '@emotion/react';
import { colors } from '@/styles/global/global';

// Shimmer 애니메이션
const shimmer = keyframes`
0% {
background-position: -468px 0;
}
100% {
background-position: 468px 0;
}
`;

interface SkeletonBaseProps {
width?: string | number;
height?: string | number;
borderRadius?: string | number;
}

// 기본 스켈레톤 스타일
export const SkeletonBase = styled.div<SkeletonBaseProps>`
display: inline-block;
width: ${({ width }) => (typeof width === 'number' ? `${width}px` : width || '100%')};
height: ${({ height }) => (typeof height === 'number' ? `${height}px` : height || '20px')};
border-radius: ${({ borderRadius }) =>
typeof borderRadius === 'number' ? `${borderRadius}px` : borderRadius || '4px'};
background: linear-gradient(
90deg,
${colors.darkgrey.main} 0%,
${colors.grey[400]} 50%,
${colors.darkgrey.main} 100%
);
background-size: 468px 100%;
animation: ${shimmer} 1.5s ease-in-out infinite;
`;
Comment on lines +22 to +36
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

borderRadius 커스텀 prop이 DOM으로 전달되어 React 개발 모드 경고 발생.

Emotion은 기본적으로 string tag(e.g. div)에 대해 유효한 HTML 어트리뷰트인 prop만 DOM으로 전달합니다. width·height는 HTML 표준 어트리뷰트이므로 전달돼도 경고가 없지만, borderRadius는 HTML 표준 어트리뷰트가 아니므로 개발 모드에서 Warning: React does not recognize the borderRadius prop on a DOM element 경고가 출력됩니다.

shouldForwardProp을 사용하여 커스텀 prop이 DOM으로 전달되지 않도록 수정하세요.

🛡️ 수정 예시
+import isPropValid from '@emotion/is-prop-valid';

-export const SkeletonBase = styled.div<SkeletonBaseProps>`
+export const SkeletonBase = styled('div', {
+  shouldForwardProp: (prop) => isPropValid(prop),
+})<SkeletonBaseProps>`
   display: inline-block;
   width: ${({ width }) => (typeof width === 'number' ? `${width}px` : width || '100%')};
   height: ${({ height }) => (typeof height === 'number' ? `${height}px` : height || '20px')};
   border-radius: ${({ borderRadius }) =>
     typeof borderRadius === 'number' ? `${borderRadius}px` : borderRadius || '4px'};
   ...
`;

SkeletonBase를 상속하는 SkeletonBox, SkeletonCircle, SkeletonTextLine에도 동일하게 적용되어야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/shared/ui/Skeleton/Skeleton.styled.ts` around lines 22 - 36, The styled
div SkeletonBase is passing the custom prop borderRadius through to the DOM
causing React warnings; update the styled component creation to use Emotion's
shouldForwardProp to filter out borderRadius (e.g., only forward native HTML
attributes like width/height) so borderRadius is not passed to the underlying
div, and apply the same shouldForwardProp change to the derived components
SkeletonBox, SkeletonCircle, and SkeletonTextLine so none of them forward the
borderRadius prop to the DOM.


// 박스 스켈레톤
export const SkeletonBox = styled(SkeletonBase)``;

// 원형 스켈레톤
export const SkeletonCircle = styled(SkeletonBase)`
border-radius: 50%;
`;

// 텍스트 라인 스켈레톤
export const SkeletonTextWrapper = styled.div<{ gap?: string | number }>`
display: flex;
flex-direction: column;
gap: ${({ gap }) => (typeof gap === 'number' ? `${gap}px` : gap || '8px')};
width: 100%;
`;

export const SkeletonTextLine = styled(SkeletonBase)`
height: ${({ height }) => (typeof height === 'number' ? `${height}px` : height || '16px')};
border-radius: 4px;
`;
Loading