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
5 changes: 2 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import Router from './pages';
import { Global } from '@emotion/react';
import { CookiesProvider } from 'react-cookie';
import { globalStyles } from './styles/global/global';
import PopupContainer from './components/common/Modal/PopupContainer';

const App = () => {
return (
<CookiesProvider>
<>
<Global styles={globalStyles} />
<Router />
<PopupContainer />
</CookiesProvider>
</>
);
};

Expand Down
75 changes: 7 additions & 68 deletions src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,87 +1,26 @@
import axios, { type AxiosResponse, type AxiosError } from 'axios';

// 하드코딩된 액세스 토큰
const ACCESS_TOKEN =
'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjksImlhdCI6MTc1NDM4MjY1MiwiZXhwIjoxNzU2OTc0NjUyfQ.CCb_F6OGe02_ITYsE-tqc2_PvSkRsxd96t8NWNIa1pI';

// 토큰 관리 유틸리티
export const TokenManager = {
setAccessToken: (token: string) => localStorage.setItem('accessToken', token),
getAccessToken: (): string | null => localStorage.getItem('accessToken'),
// setRefreshToken: (token: string) => localStorage.setItem('refreshToken', token),
// getRefreshToken: (): string | null => localStorage.getItem('refreshToken'),
clearTokens: () => {
localStorage.removeItem('accessToken');
// localStorage.removeItem('refreshToken');
},
hasValidToken: (): boolean => !!localStorage.getItem('accessToken'),
};

// API 기본 설정
// API 기본 URL
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;

// 환경변수 확인용
console.log('API_BASE_URL:', API_BASE_URL);

// axios 인스턴스 생성
// Axios 인스턴스 생성
export const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // 쿠키 자동 전송 설정
});

// 요청 인터셉터
apiClient.interceptors.request.use(
config => {
// 로컬스토리지에서 토큰 먼저 확인
const token = TokenManager.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
// 토큰이 없으면 하드코딩된 토큰 사용 (개발용)
config.headers.Authorization = ACCESS_TOKEN;
}
return config;
},
error => Promise.reject(error),
);

// 응답 인터셉터 - 토큰 만료 처리 및 에러 처리
// 응답 인터셉터 (에러 처리)
apiClient.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
const { status } = error.response || {};

// 에러 로깅
console.error('API Error:', status, error.message);

// 토큰 만료 또는 인증 실패 시 로그인 페이지로 리다이렉트
if (status === 401) {
// alert('토큰이 만료되었거나 유효하지 않습니다. 로그인 페이지로 이동합니다.');

// 현재 페이지가 로그인 페이지가 아닌 경우에만 리다이렉트
if (window.location.pathname !== '/') {
// alert('로그인이 필요합니다. 로그인 페이지로 이동합니다.');
window.location.href = '/';
}
}

// 권한 없음 (403) 에러 처리
if (status === 403) {
console.warn('접근 권한이 없습니다.');
alert('접근 권한이 없습니다.');
if (error.response?.status === 401) {
// 인증 실패 시 로그인 페이지로 리다이렉트
// window.location.href = '/';
}

// 서버 에러 (500번대) 처리
if (status && status >= 500) {
console.error('서버 오류가 발생했습니다.');
alert('서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.');
}

return Promise.reject(error);
},
);

export default apiClient;
33 changes: 33 additions & 0 deletions src/api/recentsearch/getRecentSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { apiClient } from '../index';

// 최근 검색어 유형
export type SearchType = 'USER' | 'ROOM' | 'BOOK';

// 최근 검색어 데이터 타입
export interface RecentSearchData {
recentSearchId: number;
searchTerm: string;
}

// API 응답 타입
export interface GetRecentSearchResponse {
isSuccess: boolean;
code: number;
message: string;
data: {
recentSearchList: RecentSearchData[];
};
}

// 최근 검색어 조회 API 함수
export const getRecentSearch = async (type: SearchType) => {
const response = await apiClient.get<GetRecentSearchResponse>(`/recent-search?type=${type}`);
return response.data;
};

/*
// 사용 예시
const recentUserSearches = await getRecentSearch('USER');
const recentRoomSearches = await getRecentSearch('ROOM');
const recentBookSearches = await getRecentSearch('BOOK');
*/
26 changes: 26 additions & 0 deletions src/api/users/getRecentFollowing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { apiClient } from '../index';

// 최근 글 작성자 데이터 타입
export interface RecentWriterData {
userId: number;
nickname: string;
profileImageUrl: string;
}

// API 응답 타입
export interface GetRecentFollowingResponse {
isSuccess: boolean;
code: number;
message: string;
data: {
recentWriters: RecentWriterData[];
};
}

// 최근 글을 작성한 내 팔로우 리스트 조회 API 함수
export const getRecentFollowing = async () => {
const response = await apiClient.get<GetRecentFollowingResponse>(
'/users/my-followings/recent-feeds',
);
return response.data;
};
5 changes: 5 additions & 0 deletions src/api/users/getUsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface GetUsersResponse {
export interface GetUsersParams {
keyword?: string;
size?: number;
isFinalized?: boolean;
}

export const getUsers = async (params?: GetUsersParams) => {
Expand All @@ -34,6 +35,10 @@ export const getUsers = async (params?: GetUsersParams) => {
searchParams.append('size', params.size.toString());
}

if (params?.isFinalized !== undefined) {
searchParams.append('isFinalized', params.isFinalized.toString());
}

const queryString = searchParams.toString();
const url = queryString ? `/users?${queryString}` : '/users';

Expand Down
6 changes: 4 additions & 2 deletions src/components/feed/BookInfoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const BookContainer = styled.div`
display: flex;
height: 44px;
padding: 8px 4px 8px 12px;
min-width: 280px;
max-width: 500px;
flex-direction: row;
align-items: center;
justify-content: space-between;
Expand All @@ -15,7 +17,7 @@ const BookContainer = styled.div`

.left {
overflow: hidden;
max-width: 340px;
width: 220px;
white-space: nowrap;
color: var(--color-white);
text-overflow: ellipsis;
Expand All @@ -38,7 +40,7 @@ const BookContainer = styled.div`
line-height: 24px;

.name {
max-width: 100px;
width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
Expand Down
66 changes: 40 additions & 26 deletions src/components/feed/FollowList.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
import styled from '@emotion/styled';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import rightArrow from '../../assets/feed/rightArrow.svg';
import people from '../../assets/feed/people.svg';
import character from '../../assets/feed/character.svg';
import { typography } from '@/styles/global/global';

const followerData = {
followers: [
{ userId: 1, src: 'https://placehold.co/36x36', username: 'user1' },
{ userId: 2, src: 'https://placehold.co/36x36', username: 'user2' },
{ userId: 3, src: 'https://placehold.co/36x36', username: 'user3' },
{ userId: 4, src: 'https://placehold.co/36x36', username: 'user4' },
{ userId: 5, src: 'https://placehold.co/36x36', username: 'user5' },
{ userId: 6, src: 'https://placehold.co/36x36', username: 'user6' },
{ userId: 7, src: 'https://placehold.co/36x36', username: 'user7' },
{ userId: 8, src: 'https://placehold.co/36x36', username: 'user8' },
{ userId: 9, src: 'https://placehold.co/36x36', username: 'user9' },
{ userId: 10, src: 'https://placehold.co/36x36', username: 'user10' },
{ userId: 11, src: 'https://placehold.co/36x36', username: 'user11' },
{ userId: 12, src: 'https://placehold.co/36x36', username: 'user12' },
],
};
import { getRecentFollowing, type RecentWriterData } from '@/api/users/getRecentFollowing';

const FollowList = () => {
const navigate = useNavigate();
const { followers } = followerData;
const hasFollowers = followers.length > 0;
const visible = hasFollowers ? followers.slice(0, 10) : [];
const [recentWriters, setRecentWriters] = useState<RecentWriterData[]>([]);
const [loading, setLoading] = useState(false);

// API에서 최근 글 작성한 팔로우 리스트 조회
const fetchRecentFollowing = async () => {
try {
setLoading(true);
const response = await getRecentFollowing();

if (response.isSuccess) {
setRecentWriters(response.data.recentWriters);
} else {
console.error('최근 팔로우 작성자 조회 실패:', response.message);
setRecentWriters([]);
}
} catch (error) {
console.error('최근 팔로우 작성자 조회 중 오류:', error);
setRecentWriters([]);
} finally {
setLoading(false);
}
};

// 컴포넌트 마운트 시 데이터 조회
useEffect(() => {
fetchRecentFollowing();
}, []);

const hasFollowers = recentWriters.length > 0;
const visible = hasFollowers ? recentWriters.slice(0, 10) : [];

const handleFindClick = () => {
navigate('/feed/usersearch');
navigate('/feed/search');
};

const handleMoreClick = () => {
Expand All @@ -46,13 +58,15 @@ const FollowList = () => {
<img src={people} />
<div>내 띱</div>
</div>
{hasFollowers ? (
{loading ? (
<></>
) : hasFollowers ? (
<FollowContainer>
<div className="followerList">
{visible.map(({ userId, src, username }) => (
<div className="followers" key={username} onClick={() => handleProfileClick(userId)}>
<img src={src} />
<div className="username">{username}</div>
{visible.map(({ userId, profileImageUrl, nickname }) => (
<div className="followers" key={userId} onClick={() => handleProfileClick(userId)}>
<img src={profileImageUrl} alt={nickname} />
<div className="username">{nickname}</div>
</div>
))}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/feed/UserProfileItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const UserProfileItem = ({
)}
{type === 'followerlist' && (
<div className="followlistbutton">
<div>{followerCount}명이 띱하는 중</div>
<div>{followerCount ?? 0}명이 띱하는 중</div>
<img src={rightArrow} />
</div>
)}
Expand Down
37 changes: 37 additions & 0 deletions src/hooks/useOAuthToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { apiClient } from '@/api/index';

export const useOAuthToken = () => {
const [isTokenRequested, setIsTokenRequested] = useState(false);
const navigate = useNavigate();

useEffect(() => {
const params = new URLSearchParams(window.location.search);
const loginTokenKey = params.get('loginTokenKey');

if (loginTokenKey && !isTokenRequested) {
console.log('=== 🔑 소셜 로그인 토큰 발급 요청 ===');
console.log('📋 인가코드:', loginTokenKey);

setIsTokenRequested(true);

// 서버에 토큰 발급 요청
apiClient
.post('/oauth-success', { loginTokenKey }, { withCredentials: true })
.then(response => {
console.log('✅ 토큰 발급 성공:', response.data);
// URL에서 code 파라미터 제거
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
})
.catch(error => {
console.error('❌ 토큰 발급 실패:', error);
// 에러 발생 시 로그인 페이지로 이동
navigate('/');
});
}
}, [isTokenRequested, navigate]);

return { isTokenRequested };
};
12 changes: 9 additions & 3 deletions src/hooks/useUserSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ interface UseUserSearchProps {
keyword: string;
size?: number;
delay?: number;
isFinalized?: boolean;
}

export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearchProps) => {
export const useUserSearch = ({
keyword,
size = 10,
delay = 300,
isFinalized = false,
}: UseUserSearchProps) => {
const [userList, setUserList] = useState<UserData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -27,10 +33,10 @@ export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearch
try {
setLoading(true);
setError(null);

const response = await getUsers({
keyword: searchKeyword,
size,
isFinalized,
});

const newUserList = response.data.userList;
Expand All @@ -49,7 +55,7 @@ export const useUserSearch = ({ keyword, size = 10, delay = 300 }: UseUserSearch
setLoading(false);
}
},
[size],
[size, isFinalized],
);

// 디바운스된 키워드가 변경될 때 검색 실행
Expand Down
Loading