Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/북마크 리스트 뷰 & 좋아요 추가 #64

Merged
merged 7 commits into from
Jul 13, 2024
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
2 changes: 1 addition & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const nextConfig = {
destination: 'http://3.39.102.104/api/:path*',
},
{
source: '/file',
source: '/file/:path*',
destination: 'http://3.39.102.104/file',
},
],
Expand Down
3 changes: 3 additions & 0 deletions src/apis/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ const apis = {
bookmark: {
bookmark_list: '/api/v1/book-mark/list',
bookmark_save: '/api/v1/book-mark',
bookmark_like: '/api/v1/book-mark/like',
bookmark_read: '/api/v1/book-mark/read/count',
},
category: {
category_list: '/api/v1/category/list',
category_save: '/api/v1/category',
},
fileUpload: {
file: '/file',
thumbnail: (uuid: string) => `/file/thumb/original/${uuid}`,
},
};

Expand Down
86 changes: 71 additions & 15 deletions src/apis/bookmark.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import useAuthStore from '@/stores/authStore';
import { InfiniteData, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
InfiniteData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import axios, { AxiosError, AxiosResponse } from 'axios';
import { fetchData } from '.';
import { default as apis } from './api';
import { default as apis, urlParams } from './api';

interface IBookmarkParamDataType {
pageNumber?: number;
Expand All @@ -21,7 +27,26 @@ interface IBookmarkListResponseDataType {
total: number;
lastPage: number;
};
content: [];
content: Array<{
bookMarkId: number;
categoryNames: Array<any>;
faviconUrl: string;
isFavorite: boolean;
isRead: boolean;
memo: string;
readCount: number;
representImageUrl: string;
siteName: string;
title: string;
url: string;
userInsertRepresentImage: {
extension: string;
file: string;
name: string;
size: number;
uuid: string;
};
}>;
}

/**
Expand All @@ -31,10 +56,10 @@ export const useBookmarkInfinityAPI = (params: IBookmarkParamDataType) => {
const url = apis.bookmark.bookmark_list;
const authStore = useAuthStore();

return useInfiniteQuery<any, unknown, any, any>({
return useInfiniteQuery<any, unknown, IBookmarkListResponseDataType, any>({
queryKey: [apis.bookmark.bookmark_list, params],
queryFn: async ({ pageParam }) => {
const res = await fetchData.get<IBookmarkListResponseDataType, any, any>(url, {
const res = await fetchData.get(url, {
params: { ...params, pageNumber: pageParam },
});

Expand Down Expand Up @@ -118,14 +143,6 @@ export const fetchGetMetaData = async (url: string) => {
}
};

// interface IImageUploadDataType {
// name: string;
// file: string;
// uuid: string;
// size: number;
// extension: string;
// }

/**
* 북마크 이미지 업로드
* TODO : FormData API 추가 필요
Expand All @@ -134,8 +151,6 @@ export const fetchUploadImage = async (formData: FormData) => {
const url = apis.fileUpload.file;

try {
// const res = await fetchData.post<IImageUploadDataType>(url, formData);

const res = await axios.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data',
Expand All @@ -148,3 +163,44 @@ export const fetchUploadImage = async (formData: FormData) => {
}
return;
};

/**
* 북마크 이미지 쌈네일
*/
export const useGetThumbnailImage = (uuid?: string) => {
const url = apis.fileUpload.thumbnail(uuid ?? '');

return useQuery<AxiosResponse, AxiosError, any>({
queryKey: [url],
queryFn: () => fetchData.get(url),
select: (res) => res.data.result,
staleTime: 1000 * 60 * 60,
gcTime: 1000 * 60 * 60,
enabled: !!uuid,
});
};

interface BookmarkLikeDataType {
bookMarkId: number;
isFavorite: boolean;
}

/**
* 북마크 좋아요
*/
export const useBookmarkLike = () => {
const url = apis.bookmark.bookmark_like;

return useMutation<AxiosResponse, AxiosError, BookmarkLikeDataType>({
mutationFn: (data) => fetchData.put(url + urlParams(data)),
});
};

/**
* 북마크 조회
*/
export const fetchBookmarkReadCount = (bookMarkId: number) => {
const url = apis.bookmark.bookmark_read;

return fetchData.put(url, { bookMarkId });
};
21 changes: 19 additions & 2 deletions src/apis/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { getCookie } from '@/lib/utils';
import { deleteCookie, getCookie } from '@/lib/utils';
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';

// const BASE_URL = process.env.NEXT_PUBLIC_SERVER_URL;

const axiosInstance = axios.create({
// baseURL: BASE_URL,
headers: {
'Content-Type': 'application/json',
},
Expand All @@ -15,6 +18,20 @@ axiosInstance.interceptors.request.use((config) => {
return config;
});

axiosInstance.interceptors.response.use(
(config) => {
return config;
},
(error) => {
const res = error.response.data;

// token 만료
if (res.status === 401) {
deleteCookie('accessToken');
}
},
);

export interface IFetchResponse<T> {
code: string;
message: string;
Expand Down Expand Up @@ -42,7 +59,7 @@ export const fetchData = {
return res;
},

put: async <T>(url: string, data: unknown) => {
put: async <T>(url: string, data?: unknown) => {
const res = await axiosInstance<T>({
method: 'put',
url: url,
Expand Down
58 changes: 53 additions & 5 deletions src/components/BookmarkCard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
'use client';

import { useBookmarkLike, useGetThumbnailImage } from '@/apis/bookmark';
import { cn } from '@/lib/utils';
import useToastStore from '@/stores/toastStore';
import Image from 'next/image';
import { MouseEvent, useState } from 'react';
import Icon from '../common/Icon';

export interface IBookmarkCard {
bookMarkId: number;
categoryNames: Array<number>;
Expand All @@ -11,18 +15,53 @@ export interface IBookmarkCard {
faviconUrl: string;
siteName: string;
url: string;
imageUUID?: string;
isFavorite: boolean;
onClick: () => void;
}

/**
* TODO : s3로 변경시 thumbnail 가져오는 api 삭제
*/
const BookmarkCard = ({
bookMarkId,
representImageUrl,
title,
categoryNames,
memo,
faviconUrl,
siteName,
url,
imageUUID,
isFavorite,
onClick,
}: IBookmarkCard) => {
const { addToast } = useToastStore();

// 썸네일 API 요청
useGetThumbnailImage(imageUUID);

const { mutateAsync: mutateBookmarkLike } = useBookmarkLike();

const [isLike, setIsLike] = useState(isFavorite);

// 북마크 좋아요
const handleToggleLike = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();

mutateBookmarkLike({ bookMarkId, isFavorite: !isLike }).then(() => {
setIsLike((prev) => !prev);
});
};

const handleCopyUrl = async (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();

navigator.clipboard.writeText(url).then(() => {
addToast('링크가 복사되었습니다.', 'success');
});
};

return (
<div
className='cursor-pointer max-h-[342px] mb-40 flex flex-col gap-20 group'
Expand Down Expand Up @@ -70,11 +109,20 @@ const BookmarkCard = ({
/>
</picture>
<span className='body-md text-text truncate'>{siteName}</span>
<div className='absolute right-0 hidden items-center gap-12 bg-surface *:text-icon-minimal group-hover:flex'>
<button onClick={(e) => e.stopPropagation()}>
<Icon name='heart' className='w-20 h-20' />
<div
className={cn([
'absolute right-0 hidden items-center gap-12 bg-surface *:text-icon-minimal group-hover:flex',
isLike && 'flex',
])}
>
<button onClick={handleToggleLike}>
{isLike ? (
<Icon name='heart_fill' className='w-20 h-20 text-primary' />
) : (
<Icon name='heart' className='w-20 h-20' />
)}
</button>
<button onClick={(e) => e.stopPropagation()}>
<button onClick={handleCopyUrl}>
<Icon name='link_03' className='w-20 h-20' />
</button>
<button onClick={(e) => e.stopPropagation()}>
Expand Down
102 changes: 102 additions & 0 deletions src/components/BookmarkItem/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use client';

import { useBookmarkLike } from '@/apis/bookmark';
import useToastStore from '@/stores/toastStore';
import { MouseEvent, useState } from 'react';
import Icon from '../common/Icon';
export interface IBookmarkCard {
bookMarkId: number;
categoryNames: Array<number>;
title: string;
memo: string;
faviconUrl: string;
siteName: string;
url: string;
imageUUID?: string;
isFavorite: boolean;
onClick: () => void;
}

/**
* TODO : s3로 변경시 thumbnail 가져오는 api 삭제
*/
const BookmarkItem = ({
bookMarkId,
title,
categoryNames,
memo,
faviconUrl,
siteName,
url,
isFavorite,
onClick,
}: IBookmarkCard) => {
const { addToast } = useToastStore();

const { mutateAsync: mutateBookmarkLike } = useBookmarkLike();

const [isLike, setIsLike] = useState(isFavorite);

// 북마크 좋아요
const handleToggleLike = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();

mutateBookmarkLike({ bookMarkId, isFavorite: !isLike }).then(() => {
setIsLike((prev) => !prev);
});
};

const handleCopyUrl = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();

navigator.clipboard.writeText(url).then(() => {
addToast('링크가 복사되었습니다.', 'success');
});
};

return (
<div
className='cursor-pointer h-[66px] px-40 grid grid-cols-[1fr,160px,140px,152px] even:bg-surface-sub hover:bg-action-primary-tonal'
onClick={onClick}
>
<div className='flex flex-col justify-center gap-4 px-8 overflow-hidden'>
<h2 className='body-md text-text truncate'>{title}</h2>
<p className='body-sm text-text-sub truncate'>{memo}</p>
</div>
<div className='flex items-center justify-end gap-8 px-8'>
<span className='body-md text-text truncate'>{siteName}</span>
<img
className='rounded-full'
src={faviconUrl}
alt='파비콘'
width={20}
height={20}
onError={(e) => ((e.target as HTMLImageElement).src = '/logo.svg')}
/>
</div>
<div className='flex items-center justify-end px-8'>
{categoryNames.length > 0 && <span className='body-md text-text'>{categoryNames[0]}</span>}
</div>
<div className='flex items-center justify-center gap-4 *:text-icon-minimal'>
<button className='w-40 h-40 flex items-center justify-center' onClick={handleToggleLike}>
{isLike ? (
<Icon name='heart_fill' className='w-16 h-16 text-primary' />
) : (
<Icon name='heart' className='w-16 h-16' />
)}
</button>
<button className='w-40 h-40 flex items-center justify-center' onClick={handleCopyUrl}>
<Icon name='link_03' className='w-16 h-16' />
</button>
<button
className='w-40 h-40 flex items-center justify-center'
onClick={(e) => e.stopPropagation()}
>
<Icon name='dotsVertical' className='w-20 h-20' />
</button>
</div>
</div>
);
};

export default BookmarkItem;
Loading
Loading