diff --git a/src/app/bookmarks/page.tsx b/src/app/bookmarks/page.tsx new file mode 100644 index 0000000..05b1749 --- /dev/null +++ b/src/app/bookmarks/page.tsx @@ -0,0 +1,449 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Bookmark, BookmarkFormData } from '@/types/bookmark'; +import { BookmarkAPI } from '@/libs/api/bookmarks'; +import BookmarkItem from '@/components/bookmarks/BookmarkItem'; +import BookmarkModal from '@/components/bookmarks/BookmarkModal'; +import LoginModal from '@/components/auth/LoginModal'; +import SignupModal from '@/components/auth/SignupModal'; +import ConfirmModal from '@/components/ui/ConfirmModal'; + +export default function BookmarksPage() { + const router = useRouter(); + const [bookmarks, setBookmarks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + + // Auth states + const [signupOpen, setSignupOpen] = useState(false); + const [loginOpen, setLoginOpen] = useState(false); + const [isAuthed, setIsAuthed] = useState(false); + const [userName, setUserName] = useState(undefined); + const [confirmOpen, setConfirmOpen] = useState(false); + const [selectedBookmark, setSelectedBookmark] = useState(null); + const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false); + + // 모달 상태 + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingBookmark, setEditingBookmark] = useState(); + const [modalLoading, setModalLoading] = useState(false); + + // 새로고침 시에도 로그인 유지 + useEffect(() => { + const at = localStorage.getItem('accessToken'); + const name = localStorage.getItem('userName') || undefined; + if (at) { + setIsAuthed(true); + setUserName(name); + loadBookmarks(); + } else { + setLoading(false); + } + }, []); + + const loadBookmarks = async () => { + try { + setLoading(true); + setError(null); + const data = await BookmarkAPI.getBookmarks(); + setBookmarks(data); + } catch (err) { + const msg = err instanceof Error ? err.message : '북마크를 불러오는데 실패했습니다.'; + if (msg.includes('로그인이 필요')) { + // 만료 토큰 정리 및 로그인 유도 + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userName'); + setIsAuthed(false); + setUserName(undefined); + setBookmarks([]); + setLoginOpen(true); + } else { + setError(msg); + } + } finally { + setLoading(false); + } + }; + + // 검색 필터링 + const filteredBookmarks = bookmarks.filter((bookmark) => { + const query = searchQuery.toLowerCase(); + return ( + bookmark.custom_name.toLowerCase().includes(query) || + bookmark.custom_url.toLowerCase().includes(query) + ); + }); + + // 북마크 추가 + const handleAdd = () => { + if (!isAuthed) { + setLoginOpen(true); + return; + } + setEditingBookmark(undefined); + setIsModalOpen(true); + }; + + // 북마크 수정 + const handleEdit = (bookmark: Bookmark) => { + setEditingBookmark(bookmark); + setIsModalOpen(true); + }; + + // 북마크 삭제 + const handleDelete = async (id: number) => { + if (confirm('이 북마크를 삭제하시겠습니까?')) { + try { + await BookmarkAPI.deleteBookmark(id); + setBookmarks((prev) => prev.filter((b) => b.id !== id)); + } catch (err) { + alert(err instanceof Error ? err.message : '북마크 삭제에 실패했습니다.'); + } + } + }; + + // 북마크 시간 확인 (확인 모달 표시) + const handleCheckTime = (bookmark: Bookmark) => { + setSelectedBookmark(bookmark); + setConfirmOpen(true); + }; + + // 실제 시간 확인 실행 + const executeCheckTime = async () => { + if (!selectedBookmark) return; + + try { + await BookmarkAPI.clickBookmark(selectedBookmark.id); + // 시간 확인 결과를 새 창에서 열기 + window.open(`/result?url=${encodeURIComponent(selectedBookmark.custom_url)}`, '_blank'); + setConfirmOpen(false); + setSelectedBookmark(null); + } catch (err) { + alert(err instanceof Error ? err.message : '시간 확인에 실패했습니다.'); + setConfirmOpen(false); + setSelectedBookmark(null); + } + }; + + // 모달 제출 + const handleModalSubmit = async (data: BookmarkFormData) => { + setModalLoading(true); + try { + if (editingBookmark) { + // 수정 + const updated = await BookmarkAPI.updateBookmark(editingBookmark.id, data); + setBookmarks((prev) => + prev.map((b) => (b.id === editingBookmark.id ? updated : b)), + ); + } else { + // 추가 + const created = await BookmarkAPI.createBookmark(data); + setBookmarks((prev) => [created, ...prev]); + } + setIsModalOpen(false); + } catch (err) { + alert( + err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.', + ); + } finally { + setModalLoading(false); + } + }; + + const handleLogout = () => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userName'); + setIsAuthed(false); + setUserName(undefined); + setLogoutConfirmOpen(false); + setBookmarks([]); + alert('로그아웃 되었습니다.'); + router.push('/'); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+ +
+ ⏰ +
+ Check Time + + + + +
+ {isAuthed ? ( + <> + 안녕하세요, {userName}님 + + + ) : ( + <> + + + + )} +
+
+
+ + {/* 메인 컨텐츠 */} +
+ {/* 컨트롤 바 */} +
+
+ setSearchQuery(e.target.value)} + /> +
+ +
+
+ + +
+ + +
+
+ + {/* 북마크 컨테이너 */} +
+ {!isAuthed ? ( +
+
🔒
+

+ 로그인이 필요합니다 +

+

북마크 기능을 사용하려면 로그인해주세요

+
+ + +
+
+ ) : error ? ( +
+
{error}
+ +
+ ) : filteredBookmarks.length === 0 ? ( +
+

+ {searchQuery ? '검색 결과가 없습니다' : '아직 북마크가 없습니다'} +

+

+ {searchQuery ? '다른 검색어를 시도해보세요' : '첫 번째 북마크를 추가해보세요!'} +

+
+ ) : ( +
+ {filteredBookmarks.map((bookmark) => ( + + ))} +
+ )} +
+
+ + {/* 북마크 추가/수정 모달 */} + setIsModalOpen(false)} + bookmark={editingBookmark} + onSubmit={handleModalSubmit} + isLoading={modalLoading} + /> + + {/* 로그인 모달 */} + setLoginOpen(false)} + onSignupClick={() => { + setLoginOpen(false); + setSignupOpen(true); + }} + onSubmit={async ({ email, password }) => { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE}/api/auth/login`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }, + ); + + const data = await res.json(); + + if (!res.ok) throw new Error(data.error || '로그인 실패'); + + localStorage.setItem('accessToken', data.data.accessToken); + localStorage.setItem('refreshToken', data.data.refreshToken); + if (data?.data?.user?.username) { + localStorage.setItem('userName', data.data.user.username); + setUserName(data.data.user.username); + } + setIsAuthed(true); + setLoginOpen(false); + loadBookmarks(); + return true; + } catch (err) { + alert(err instanceof Error ? err.message : '로그인 중 오류 발생'); + return false; + } + }} + /> + + {/* 회원가입 모달 */} + setSignupOpen(false)} + onLoginClick={() => { + setSignupOpen(false); + setLoginOpen(true); + }} + onSubmit={async ({ username, email, password }) => { + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE}/api/auth/register`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, email, password }), + }, + ); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || '회원가입 실패'); + } + + console.log('회원가입 성공', data.data.user); + alert('회원가입이 완료되었습니다. 로그인 해주세요.'); + setSignupOpen(false); + setLoginOpen(true); + } catch (err) { + alert(err instanceof Error ? err.message : '회원가입 중 오류 발생'); + } + }} + /> + + {/* 시간확인 확인 모달 */} + { + setConfirmOpen(false); + setSelectedBookmark(null); + }} + /> + + {/* 로그아웃 확인 모달 */} + setLogoutConfirmOpen(false)} + /> +
+ ); +} \ No newline at end of file diff --git a/src/components/bookmarks/BookmarkForm.tsx b/src/components/bookmarks/BookmarkForm.tsx new file mode 100644 index 0000000..83b9755 --- /dev/null +++ b/src/components/bookmarks/BookmarkForm.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { BookmarkFormData, Bookmark } from '@/types/bookmark'; + +interface BookmarkFormProps { + bookmark?: Bookmark; // 수정 모드일 때 전달 + onSubmit: (data: BookmarkFormData) => Promise; + onCancel: () => void; + isLoading?: boolean; +} + +export default function BookmarkForm({ + bookmark, + onSubmit, + onCancel, + isLoading, +}: BookmarkFormProps) { + const [formData, setFormData] = useState({ + custom_name: '', + custom_url: '', + favicon: '', + }); + + const [errors, setErrors] = useState>({}); + + useEffect(() => { + if (bookmark) { + setFormData({ + custom_name: bookmark.custom_name, + custom_url: bookmark.custom_url, + favicon: bookmark.favicon || '', + }); + } + }, [bookmark]); + + const validateForm = (): boolean => { + const newErrors: Partial = {}; + + if (!formData.custom_name.trim()) { + newErrors.custom_name = '북마크 이름을 입력해주세요.'; + } + + if (!formData.custom_url.trim()) { + newErrors.custom_url = 'URL을 입력해주세요.'; + } else if (!isValidUrl(formData.custom_url)) { + newErrors.custom_url = '올바른 URL 형식이 아닙니다.'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const isValidUrl = (url: string): boolean => { + try { + new URL(url); + return true; + } catch { + return false; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + try { + await onSubmit(formData); + } catch (error) { + console.error('Form submission error:', error); + } + }; + + const handleChange = (field: keyof BookmarkFormData, value: string) => { + setFormData((prev: BookmarkFormData) => ({ ...prev, [field]: value })); + // 에러가 있었다면 입력시 제거 + if (errors[field]) { + setErrors((prev: Partial) => ({ + ...prev, + [field]: undefined, + })); + } + }; + + return ( +
+ {/* 북마크 이름 */} +
+ + handleChange('custom_name', e.target.value)} + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.custom_name ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="예: 인터파크 티켓" + disabled={isLoading} + /> + {errors.custom_name && ( +

{errors.custom_name}

+ )} +
+ + {/* URL */} +
+ + handleChange('custom_url', e.target.value)} + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.custom_url ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="https://example.com" + disabled={isLoading} + /> + {errors.custom_url && ( +

{errors.custom_url}

+ )} +
+ + {/* 파비콘 URL (선택사항) */} +
+ + handleChange('favicon', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="https://example.com/favicon.ico" + disabled={isLoading} + /> +
+ + {/* 버튼들 */} +
+ + +
+
+ ); +} diff --git a/src/components/bookmarks/BookmarkItem.tsx b/src/components/bookmarks/BookmarkItem.tsx new file mode 100644 index 0000000..964d980 --- /dev/null +++ b/src/components/bookmarks/BookmarkItem.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { Bookmark } from '@/types/bookmark'; + +interface BookmarkItemProps { + bookmark: Bookmark; + onEdit: (bookmark: Bookmark) => void; + onDelete: (id: number) => void; + onCheckTime: (bookmark: Bookmark) => void; + viewMode: 'grid' | 'list'; +} + +export default function BookmarkItem({ + bookmark, + onEdit, + onDelete, + onCheckTime, + viewMode, +}: BookmarkItemProps) { + const getFaviconUrl = (url: string) => { + try { + const urlObj = new URL(url); + return `${urlObj.origin}/favicon.ico`; + } catch { + return null; + } + }; + + const faviconUrl = getFaviconUrl(bookmark.custom_url); + + if (viewMode === 'list') { + return ( +
onCheckTime(bookmark)} + > +
+ {/* 아이콘 */} +
+ {bookmark.favicon && faviconUrl ? ( + {bookmark.custom_name} { + e.currentTarget.style.display = 'none'; + const nextElement = e.currentTarget.nextElementSibling as HTMLElement; + if (nextElement) nextElement.style.display = 'block'; + }} + /> + ) : null} + + +
+ + {/* 내용 */} +
+

+ {bookmark.custom_name} +

+

+ {bookmark.custom_url} +

+
+ + {/* 액션 버튼들 */} +
+ + +
+
+
+ ); + } + + // Grid view + return ( +
onCheckTime(bookmark)} + > + {/* 아이콘 */} +
+ {bookmark.favicon && faviconUrl ? ( + {bookmark.custom_name} { + e.currentTarget.style.display = 'none'; + const nextElement = e.currentTarget.nextElementSibling as HTMLElement; + if (nextElement) nextElement.style.display = 'block'; + }} + /> + ) : null} + + +
+ + {/* 제목 */} +

+ {bookmark.custom_name} +

+ + {/* URL */} +

+ {bookmark.custom_url} +

+ + {/* 액션 버튼들 */} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/bookmarks/BookmarkList.tsx b/src/components/bookmarks/BookmarkList.tsx new file mode 100644 index 0000000..faba444 --- /dev/null +++ b/src/components/bookmarks/BookmarkList.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Bookmark, BookmarkFormData } from '@/types/bookmark'; +import { BookmarkAPI } from '@/libs/api/bookmarks'; +import BookmarkItem from './BookmarkItem'; +import BookmarkModal from './BookmarkModal'; +import ConfirmModal from '@/components/ui/ConfirmModal'; + +export default function BookmarkList() { + const [bookmarks, setBookmarks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 모달 상태 + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingBookmark, setEditingBookmark] = useState< + Bookmark | undefined + >(); + const [modalLoading, setModalLoading] = useState(false); + + // 시간확인 확인 모달 상태 + const [confirmModalOpen, setConfirmModalOpen] = useState(false); + const [selectedBookmark, setSelectedBookmark] = useState(null); + + // 초기 데이터 로딩 + useEffect(() => { + loadBookmarks(); + }, []); + + const loadBookmarks = async () => { + try { + setLoading(true); + setError(null); + const data = await BookmarkAPI.getBookmarks(); + setBookmarks(data); + } catch (err) { + const msg = err instanceof Error ? err.message : '북마크를 불러오는데 실패했습니다.'; + if (msg.includes('로그인이 필요')) { + // 만료 토큰 정리 및 로그인 유도 + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userName'); + // BookmarkList는 독립 컴포넌트이므로 상위에서 처리 필요 + setError('로그인이 만료되었습니다. 다시 로그인해주세요.'); + } else { + setError(msg); + } + } finally { + setLoading(false); + } + }; + + // 북마크 추가 + const handleAdd = () => { + setEditingBookmark(undefined); + setIsModalOpen(true); + }; + + // 북마크 수정 + const handleEdit = (bookmark: Bookmark) => { + setEditingBookmark(bookmark); + setIsModalOpen(true); + }; + + // 북마크 삭제 + const handleDelete = async (id: number) => { + try { + await BookmarkAPI.deleteBookmark(id); + setBookmarks((prev) => prev.filter((b) => b.id !== id)); + } catch (err) { + alert(err instanceof Error ? err.message : '북마크 삭제에 실패했습니다.'); + } + }; + + // 북마크 시간 확인 (확인 모달 표시) + const handleCheckTime = (bookmark: Bookmark) => { + setSelectedBookmark(bookmark); + setConfirmModalOpen(true); + }; + + // 실제 시간 확인 실행 + const executeCheckTime = async () => { + if (!selectedBookmark) return; + + try { + await BookmarkAPI.clickBookmark(selectedBookmark.id); + // 시간 확인 결과를 새 창에서 열기 + window.open(`/result?url=${encodeURIComponent(selectedBookmark.custom_url)}`, '_blank'); + setConfirmModalOpen(false); + setSelectedBookmark(null); + } catch (err) { + alert(err instanceof Error ? err.message : '시간 확인에 실패했습니다.'); + setConfirmModalOpen(false); + setSelectedBookmark(null); + } + }; + + // 모달 제출 + const handleModalSubmit = async (data: BookmarkFormData) => { + setModalLoading(true); + try { + if (editingBookmark) { + // 수정 + const updated = await BookmarkAPI.updateBookmark( + editingBookmark.id, + data, + ); + setBookmarks((prev) => + prev.map((b) => (b.id === editingBookmark.id ? updated : b)), + ); + } else { + // 추가 + const created = await BookmarkAPI.createBookmark(data); + setBookmarks((prev) => [created, ...prev]); + } + setIsModalOpen(false); + } catch (err) { + alert( + err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.', + ); + } finally { + setModalLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

내 북마크

+

+ 자주 방문하는 사이트를 저장하고 빠르게 접근하세요. +

+
+ +
+ + {/* 북마크 목록 */} +
+ {bookmarks.map((bookmark) => ( + + ))} +
+ + {/* 북마크 추가/수정 모달 */} + setIsModalOpen(false)} + bookmark={editingBookmark} + onSubmit={handleModalSubmit} + isLoading={modalLoading} + /> + + {/* 시간확인 확인 모달 */} + { + setConfirmModalOpen(false); + setSelectedBookmark(null); + }} + /> +
+ ); +} diff --git a/src/components/bookmarks/BookmarkModal.tsx b/src/components/bookmarks/BookmarkModal.tsx new file mode 100644 index 0000000..66f7834 --- /dev/null +++ b/src/components/bookmarks/BookmarkModal.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Bookmark, BookmarkFormData } from '@/types/bookmark'; + +interface BookmarkModalProps { + isOpen: boolean; + onClose: () => void; + bookmark?: Bookmark; // 수정 모드일 때 전달 + onSubmit: (data: BookmarkFormData) => Promise; + isLoading?: boolean; +} + + +export default function BookmarkModal({ + isOpen, + onClose, + bookmark, + onSubmit, + isLoading, +}: BookmarkModalProps) { + const [formData, setFormData] = useState({ + custom_name: '', + custom_url: '', + favicon: '', + }); + const [errors, setErrors] = useState>({}); + + useEffect(() => { + if (bookmark) { + setFormData({ + custom_name: bookmark.custom_name, + custom_url: bookmark.custom_url, + favicon: bookmark.favicon || '', + }); + } else { + setFormData({ + custom_name: '', + custom_url: '', + favicon: '', + }); + } + setErrors({}); + }, [bookmark, isOpen]); + + // ESC 키로 모달 닫기 + useEffect(() => { + const handleEscKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscKey); + } + + return () => { + document.removeEventListener('keydown', handleEscKey); + }; + }, [isOpen, onClose]); + + const validateForm = (): boolean => { + const newErrors: Partial = {}; + + if (!formData.custom_name.trim()) { + newErrors.custom_name = '북마크 이름을 입력해주세요.'; + } + + if (!formData.custom_url.trim()) { + newErrors.custom_url = 'URL을 입력해주세요.'; + } else if (!isValidUrl(formData.custom_url)) { + newErrors.custom_url = '올바른 URL 형식이 아닙니다.'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const isValidUrl = (url: string): boolean => { + try { + new URL(url); + return true; + } catch { + return false; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + try { + const submitData: any = { + custom_name: formData.custom_name.trim(), + custom_url: formData.custom_url.trim(), + // favicon 필드는 전송하지 않음 (백엔드에서 자동 처리) + }; + + await onSubmit(submitData); + } catch (error) { + console.error('Form submission error:', error); + } + }; + + const handleChange = (field: keyof BookmarkFormData, value: string) => { + setFormData((prev: BookmarkFormData) => ({ ...prev, [field]: value })); + // 에러가 있었다면 입력시 제거 + if (errors[field]) { + setErrors((prev: Partial) => ({ + ...prev, + [field]: undefined, + })); + } + }; + + if (!isOpen) return null; + + return ( +
+ {/* 배경 오버레이 */} + +
+ + {/* 폼 내용 */} +
+ {/* 북마크 이름 */} +
+ + handleChange('custom_name', e.target.value)} + className={`w-full px-4 py-3 border rounded-lg text-sm outline-none transition-all bg-gray-50 focus:bg-white focus:border-indigo-500 focus:ring-4 focus:ring-indigo-100 ${ + errors.custom_name ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="사이트 이름을 입력하세요" + disabled={isLoading} + /> + {errors.custom_name && ( +

{errors.custom_name}

+ )} +
+ + {/* URL */} +
+ + handleChange('custom_url', e.target.value)} + className={`w-full px-4 py-3 border rounded-lg text-sm outline-none transition-all bg-gray-50 focus:bg-white focus:border-indigo-500 focus:ring-4 focus:ring-indigo-100 ${ + errors.custom_url ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="https://example.com" + disabled={isLoading} + /> + {errors.custom_url && ( +

{errors.custom_url}

+ )} +
+ + + {/* 버튼들 */} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx index f569e5a..aef83ae 100644 --- a/src/components/ui/Header.tsx +++ b/src/components/ui/Header.tsx @@ -58,7 +58,10 @@ export default function Header(props: HeaderProps) { 반응속도 게임 - + 북마크 diff --git a/src/libs/api/bookmarks.ts b/src/libs/api/bookmarks.ts new file mode 100644 index 0000000..368e16f --- /dev/null +++ b/src/libs/api/bookmarks.ts @@ -0,0 +1,199 @@ +import { + Bookmark, + BookmarkCreateRequest, + BookmarkUpdateRequest, +} from '@/types/bookmark'; +import { AuthUtils } from '@/libs/auth'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE; + +export class BookmarkAPI { + // 북마크 목록 조회 + static async getBookmarks(): Promise { + const response = await fetch(`${API_BASE_URL}/api/bookmarks`, { + headers: AuthUtils.getAuthHeaders(), + }); + + if (response.status === 401) { + throw new Error('로그인이 필요합니다. 다시 로그인해주세요.'); + } + + if (!response.ok) { + throw new Error('북마크 목록을 가져오는데 실패했습니다.'); + } + + const result = await response.json(); + + // API 응답 구조에 따라 데이터 추출 + if (result.success && result.data) { + if (Array.isArray(result.data)) { + return result.data; + } else { + return []; + } + } else if (Array.isArray(result)) { + return result; + } else { + console.log('북마크 API 응답:', result); + return []; + } + } + + // 북마크 추가 + static async createBookmark(data: BookmarkCreateRequest): Promise { + console.log('북마크 추가 요청 데이터:', data); + + const response = await fetch(`${API_BASE_URL}/api/bookmarks`, { + method: 'POST', + headers: { + ...AuthUtils.getAuthHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + console.log('북마크 추가 응답 상태:', response.status); + + if (response.status === 401) { + throw new Error('로그인이 필요합니다. 다시 로그인해주세요.'); + } + + if (!response.ok) { + const errorData = await response.text(); + console.error('북마크 추가 실패 응답:', errorData); + + try { + const errorJson = JSON.parse(errorData); + throw new Error( + errorJson.error || + errorJson.message || + `북마크 추가에 실패했습니다. (${response.status})`, + ); + } catch { + throw new Error( + `북마크 추가에 실패했습니다. (${response.status}): ${errorData}`, + ); + } + } + + const result = await response.json(); + console.log('북마크 추가 성공 응답:', result); + + // 추가는 { success: true, data: {...} } 형태로 반환 + if (result.success && result.data) { + return result.data; + } + + throw new Error('북마크 추가 응답 형식이 올바르지 않습니다.'); + } + + // 북마크 수정 + static async updateBookmark( + id: number, + data: BookmarkUpdateRequest, + ): Promise { + console.log('북마크 수정 요청 데이터:', data); + + const response = await fetch(`${API_BASE_URL}/api/bookmarks/${id}`, { + method: 'PUT', + headers: { + ...AuthUtils.getAuthHeaders(), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (response.status === 401) { + throw new Error('로그인이 필요합니다. 다시 로그인해주세요.'); + } + + if (!response.ok) { + const errorData = await response.text(); + console.error('북마크 수정 실패 응답:', errorData); + + try { + const errorJson = JSON.parse(errorData); + throw new Error( + errorJson.error || + errorJson.message || + `북마크 수정에 실패했습니다. (${response.status})`, + ); + } catch { + throw new Error( + `북마크 수정에 실패했습니다. (${response.status}): ${errorData}`, + ); + } + } + + const result = await response.json(); + console.log('북마크 수정 응답:', result); + + // 다양한 응답 형식 처리 + if (result.success) { + if (result.data) { + // { success: true, data: {} } 형태 + return result.data; + } else if (Array.isArray(result)) { + // 배열 형태로 직접 반환된 경우 + return result[0]; + } else if (result.id) { + // 직접 북마크 객체가 반환된 경우 + return result; + } + } else if (result.id) { + // success 없이 직접 북마크 객체가 반환된 경우 + return result; + } else if (Array.isArray(result) && result.length > 0) { + // 배열로 직접 반환된 경우 + return result[0]; + } + + console.error('북마크 수정 응답 형식 오류:', result); + throw new Error('북마크 수정 응답 형식이 올바르지 않습니다.'); + } + + // 북마크 삭제 + static async deleteBookmark(id: number): Promise { + const response = await fetch(`${API_BASE_URL}/api/bookmarks/${id}`, { + method: 'DELETE', + headers: AuthUtils.getAuthHeaders(), + }); + + if (response.status === 401) { + throw new Error('로그인이 필요합니다. 다시 로그인해주세요.'); + } + + if (!response.ok) { + throw new Error('북마크 삭제에 실패했습니다.'); + } + + const result = await response.json(); + + // 삭제는 { success: true, data: [...] } 형태로 반환하지만 성공이면 OK + if (!result.success) { + throw new Error('북마크 삭제에 실패했습니다.'); + } + } + + // 북마크 클릭 (조회수 증가) + static async clickBookmark(id: number): Promise { + const response = await fetch(`${API_BASE_URL}/api/bookmarks/${id}/click`, { + headers: AuthUtils.getAuthHeaders(), + }); + + if (response.status === 401) { + throw new Error('로그인이 필요합니다. 다시 로그인해주세요.'); + } + + if (!response.ok) { + throw new Error('북마크 클릭 처리에 실패했습니다.'); + } + + const result = await response.json(); + + // 클릭은 검색 결과를 반환하므로 성공 여부만 확인 + if (!result.success) { + throw new Error('북마크 클릭 처리에 실패했습니다.'); + } + } +} diff --git a/src/libs/auth.ts b/src/libs/auth.ts new file mode 100644 index 0000000..6f0486f --- /dev/null +++ b/src/libs/auth.ts @@ -0,0 +1,37 @@ +// JWT 토큰 관련 유틸리티 함수들 + +export const AuthUtils = { + // 토큰 가져오기 + getToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem('accessToken'); + }, + + // 토큰 저장 + setToken(token: string): void { + if (typeof window === 'undefined') return; + localStorage.setItem('accessToken', token); + }, + + // 토큰 삭제 + removeToken(): void { + if (typeof window === 'undefined') return; + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userName'); + }, + + // 토큰이 있는지 확인 + hasToken(): boolean { + return !!this.getToken(); + }, + + // 인증 헤더 생성 + getAuthHeaders(): HeadersInit { + const token = this.getToken(); + return { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + }; + }, +}; diff --git a/src/types/bookmark.ts b/src/types/bookmark.ts new file mode 100644 index 0000000..c628985 --- /dev/null +++ b/src/types/bookmark.ts @@ -0,0 +1,28 @@ +export interface Bookmark { + id: number; + user_id?: number; + custom_name: string; + custom_url: string; + favicon?: string; + created_at?: string; + updated_at?: string; + click_count?: number; +} + +export interface BookmarkCreateRequest { + custom_name: string; + custom_url: string; + favicon?: string; // 선택적 필드로 유지 (백엔드에서 사용) +} + +export interface BookmarkUpdateRequest { + custom_name: string; + custom_url: string; + favicon?: string; +} + +export interface BookmarkFormData { + custom_name: string; + custom_url: string; + favicon?: string; +}