From 9cafb88c06c487e65df45c81fc84d405b0862809 Mon Sep 17 00:00:00 2001 From: hannah0352 Date: Fri, 22 Aug 2025 18:39:31 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EC=9D=BC=EB=8B=A8=20=EC=98=A4=EB=8A=98?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=ED=95=9C=EA=B1=B0=20=EC=BB=A4=EB=B0=8B..?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/bookmarks/page.tsx | 145 ++++++++++++++++ src/components/bookmarks/BookmarkForm.tsx | 175 +++++++++++++++++++ src/components/bookmarks/BookmarkItem.tsx | 187 +++++++++++++++++++++ src/components/bookmarks/BookmarkList.tsx | 184 ++++++++++++++++++++ src/components/bookmarks/BookmarkModal.tsx | 94 +++++++++++ src/components/ui/Header.tsx | 5 +- src/libs/api/bookmarks.ts | 165 ++++++++++++++++++ src/libs/auth.ts | 37 ++++ src/types/bookmark.ts | 27 +++ 9 files changed, 1018 insertions(+), 1 deletion(-) create mode 100644 src/app/bookmarks/page.tsx create mode 100644 src/components/bookmarks/BookmarkForm.tsx create mode 100644 src/components/bookmarks/BookmarkItem.tsx create mode 100644 src/components/bookmarks/BookmarkList.tsx create mode 100644 src/components/bookmarks/BookmarkModal.tsx create mode 100644 src/libs/api/bookmarks.ts create mode 100644 src/libs/auth.ts create mode 100644 src/types/bookmark.ts diff --git a/src/app/bookmarks/page.tsx b/src/app/bookmarks/page.tsx new file mode 100644 index 0000000..d4a0755 --- /dev/null +++ b/src/app/bookmarks/page.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import Header from '@/components/ui/Header'; +import BookmarkList from '@/components/bookmarks/BookmarkList'; +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 [signupOpen, setSignupOpen] = useState(false); + const [loginOpen, setLoginOpen] = useState(false); + const [isAuthed, setIsAuthed] = useState(false); + const [userName, setUserName] = useState(undefined); + const [confirmOpen, setConfirmOpen] = useState(false); + + // 새로고침 시에도 로그인 유지 + useEffect(() => { + const at = localStorage.getItem('accessToken'); + const name = localStorage.getItem('userName') || undefined; + if (at) { + setIsAuthed(true); + setUserName(name); + } + }, []); + + const handleLogout = () => { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('userName'); + setIsAuthed(false); + setUserName(undefined); + setConfirmOpen(false); + + alert('로그아웃 되었습니다.'); + router.push('/'); // 홈으로 리다이렉트 + }; + + const headerProps: React.ComponentProps = isAuthed + ? { + isAuthed: true as const, + userName: userName!, + onLogoutClick: () => setConfirmOpen(true), + } + : { + onLoginClick: () => setLoginOpen(true), + }; + + return ( +
+ {/* Header */} +
+ + {/* Main Content */} +
+ +
+ + {/* 로그인 모달 */} + 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); + return true; + } catch (err) { + alert(err instanceof Error ? err.message : '로그인 중 오류 발생'); + return false; // 실패 시 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)} + /> +
+ ); +} 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..694d1a8 --- /dev/null +++ b/src/components/bookmarks/BookmarkItem.tsx @@ -0,0 +1,187 @@ +'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 { + // 빈 favicon 필드 제거 + const submitData: BookmarkFormData = { + custom_name: formData.custom_name.trim(), + custom_url: formData.custom_url.trim(), + }; + + // favicon이 있을 때만 포함 + if (formData.favicon && formData.favicon.trim()) { + submitData.favicon = formData.favicon.trim(); + } + + console.log('폼 제출 데이터:', submitData); + 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, + })); + } + }; + + 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/BookmarkList.tsx b/src/components/bookmarks/BookmarkList.tsx new file mode 100644 index 0000000..5f38b54 --- /dev/null +++ b/src/components/bookmarks/BookmarkList.tsx @@ -0,0 +1,184 @@ +'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'; + +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); + + // 초기 데이터 로딩 + useEffect(() => { + loadBookmarks(); + }, []); + + const loadBookmarks = async () => { + try { + setLoading(true); + setError(null); + const data = await BookmarkAPI.getBookmarks(); + setBookmarks(data); + } catch (err) { + setError( + err instanceof Error + ? err.message + : '북마크를 불러오는데 실패했습니다.', + ); + } 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 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.length === 0 ? ( +
+
📚
+

+ 아직 북마크가 없습니다 +

+

첫 번째 북마크를 추가해보세요!

+ +
+ ) : ( +
+ {bookmarks.map((bookmark) => ( + + ))} +
+ )} + + {/* 북마크 추가/수정 모달 */} + setIsModalOpen(false)} + bookmark={editingBookmark} + onSubmit={handleModalSubmit} + isLoading={modalLoading} + /> +
+ ); +} diff --git a/src/components/bookmarks/BookmarkModal.tsx b/src/components/bookmarks/BookmarkModal.tsx new file mode 100644 index 0000000..3208c2a --- /dev/null +++ b/src/components/bookmarks/BookmarkModal.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useEffect } from 'react'; +import { Bookmark, BookmarkFormData } from '@/types/bookmark'; +import BookmarkForm from './BookmarkForm'; + +interface BookmarkModalProps { + isOpen: boolean; + onClose: () => void; + bookmark?: Bookmark; // 수정 모드일 때 전달 + onSubmit: (data: BookmarkFormData) => Promise; + isLoading?: boolean; +} + +export default function BookmarkModal({ + isOpen, + onClose, + bookmark, + onSubmit, + isLoading, +}: BookmarkModalProps) { + // ESC 키로 모달 닫기 + useEffect(() => { + const handleEscKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscKey); + } + + return () => { + document.removeEventListener('keydown', handleEscKey); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+ {/* 배경 오버레이 */} + +
+ + {/* 폼 내용 */} +
+ +
+
+ + ); +} 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..6824095 --- /dev/null +++ b/src/libs/api/bookmarks.ts @@ -0,0 +1,165 @@ +import { + Bookmark, + BookmarkCreateRequest, + BookmarkUpdateRequest, +} from '@/types/bookmark'; +import { AuthUtils } from '@/libs/auth'; + +const API_BASE_URL = 'http://localhost:3001/api'; + +export class BookmarkAPI { + // 북마크 목록 조회 + static async getBookmarks(): Promise { + const response = await fetch(`${API_BASE_URL}/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}/bookmarks`, { + method: 'POST', + headers: AuthUtils.getAuthHeaders(), + 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 { + const response = await fetch(`${API_BASE_URL}/bookmarks/${id}`, { + method: 'PUT', + headers: AuthUtils.getAuthHeaders(), + body: JSON.stringify(data), + }); + + if (response.status === 401) { + throw new Error('로그인이 필요합니다. 다시 로그인해주세요.'); + } + + if (!response.ok) { + throw new Error('북마크 수정에 실패했습니다.'); + } + + const result = await response.json(); + + // 수정은 { success: true, data: [{}] } 형태로 반환 (배열) + if ( + result.success && + result.data && + Array.isArray(result.data) && + result.data.length > 0 + ) { + return result.data[0]; + } + + throw new Error('북마크 수정 응답 형식이 올바르지 않습니다.'); + } + + // 북마크 삭제 + static async deleteBookmark(id: number): Promise { + const response = await fetch(`${API_BASE_URL}/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}/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..6462fe0 --- /dev/null +++ b/src/types/bookmark.ts @@ -0,0 +1,27 @@ +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; +} + +export interface BookmarkUpdateRequest { + custom_name: string; + custom_url: string; + favicon?: string; +} + +export interface BookmarkFormData { + custom_name: string; + custom_url: string; + favicon?: string; +} From 3c799d3e06733419c2f6fc11ac23e6b1f515ab9f Mon Sep 17 00:00:00 2001 From: hannah0352 Date: Sat, 20 Sep 2025 00:21:48 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EB=B6=81=EB=A7=88=ED=81=AC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C/=EC=B6=94=EA=B0=80/=EC=88=98=EC=A0=95/?= =?UTF-8?q?=EC=82=AD=EC=A0=9C/=ED=81=B4=EB=A6=AD=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/bookmarks/page.tsx | 345 +++++++++++++++++++-- src/components/bookmarks/BookmarkItem.tsx | 292 ++++++++--------- src/components/bookmarks/BookmarkList.tsx | 80 +++-- src/components/bookmarks/BookmarkModal.tsx | 178 +++++++++-- src/libs/api/bookmarks.ts | 68 +++- src/types/bookmark.ts | 1 + 6 files changed, 705 insertions(+), 259 deletions(-) diff --git a/src/app/bookmarks/page.tsx b/src/app/bookmarks/page.tsx index d4a0755..2517f8b 100644 --- a/src/app/bookmarks/page.tsx +++ b/src/app/bookmarks/page.tsx @@ -2,19 +2,36 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import Header from '@/components/ui/Header'; -import BookmarkList from '@/components/bookmarks/BookmarkList'; +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(() => { @@ -23,40 +40,304 @@ export default function BookmarksPage() { 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) { + setError( + err instanceof Error + ? err.message + : '북마크를 불러오는데 실패했습니다.', + ); + } 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); - setConfirmOpen(false); - + setLogoutConfirmOpen(false); + setBookmarks([]); alert('로그아웃 되었습니다.'); - router.push('/'); // 홈으로 리다이렉트 + router.push('/'); }; - const headerProps: React.ComponentProps = isAuthed - ? { - isAuthed: true as const, - userName: userName!, - onLogoutClick: () => setConfirmOpen(true), - } - : { - onLoginClick: () => setLoginOpen(true), - }; + if (loading) { + return ( +
+
+
+ ); + } return (
- {/* Header */} -
+ {/* 헤더 */} +
+
+ +
+ ⏰ +
+ Check Time + + + + +
+ {isAuthed ? ( + <> + 안녕하세요, {userName}님 + + + ) : ( + <> + + + + )} +
+
+
- {/* Main Content */} -
- + {/* 메인 컨텐츠 */} +
+ {/* 컨트롤 바 */} +
+
+ setSearchQuery(e.target.value)} + /> +
+ +
+
+ +
+ + +
+
+ + {/* 북마크 컨테이너 */} +
+ {!isAuthed ? ( +
+
🔒
+

+ 로그인이 필요합니다 +

+

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

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

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

+

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

+
+ ) : ( +
+ {filteredBookmarks.map((bookmark) => ( + + ))} +
+ )} +
+
+ + {/* 북마크 추가/수정 모달 */} + setIsModalOpen(false)} + bookmark={editingBookmark} + onSubmit={handleModalSubmit} + isLoading={modalLoading} + /> {/* 로그인 모달 */} @@ -123,23 +406,37 @@ export default function BookmarksPage() { console.log('회원가입 성공', data.data.user); alert('회원가입이 완료되었습니다. 로그인 해주세요.'); setSignupOpen(false); - setLoginOpen(true); // 바로 로그인 유도 + setLoginOpen(true); } catch (err) { alert(err instanceof Error ? err.message : '회원가입 중 오류 발생'); } }} /> - {/* 로그아웃 확인 모달 */} + {/* 시간확인 확인 모달 */} { + setConfirmOpen(false); + setSelectedBookmark(null); + }} + /> + + {/* 로그아웃 확인 모달 */} + setConfirmOpen(false)} + onClose={() => setLogoutConfirmOpen(false)} />
); -} +} \ No newline at end of file diff --git a/src/components/bookmarks/BookmarkItem.tsx b/src/components/bookmarks/BookmarkItem.tsx index 694d1a8..964d980 100644 --- a/src/components/bookmarks/BookmarkItem.tsx +++ b/src/components/bookmarks/BookmarkItem.tsx @@ -1,187 +1,153 @@ '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; +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 BookmarkForm({ +export default function BookmarkItem({ 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 => { + onEdit, + onDelete, + onCheckTime, + viewMode, +}: BookmarkItemProps) { + const getFaviconUrl = (url: string) => { try { - new URL(url); - return true; + const urlObj = new URL(url); + return `${urlObj.origin}/favicon.ico`; } catch { - return false; + return null; } }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!validateForm()) return; - - try { - // 빈 favicon 필드 제거 - const submitData: BookmarkFormData = { - custom_name: formData.custom_name.trim(), - custom_url: formData.custom_url.trim(), - }; - - // favicon이 있을 때만 포함 - if (formData.favicon && formData.favicon.trim()) { - submitData.favicon = formData.favicon.trim(); - } - - console.log('폼 제출 데이터:', submitData); - 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, - })); - } - }; + 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 ( -
- {/* 북마크 이름 */} -
- - 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}

- )} +
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} + +
- {/* 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}

- )} -
+ {/* 제목 */} +

+ {bookmark.custom_name} +

- {/* 파비콘 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} - /> -
+ {/* URL */} +

+ {bookmark.custom_url} +

- {/* 버튼들 */} -
+ {/* 액션 버튼들 */} +
- +
); -} +} \ No newline at end of file diff --git a/src/components/bookmarks/BookmarkList.tsx b/src/components/bookmarks/BookmarkList.tsx index 5f38b54..e3f2cdb 100644 --- a/src/components/bookmarks/BookmarkList.tsx +++ b/src/components/bookmarks/BookmarkList.tsx @@ -5,6 +5,7 @@ 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([]); @@ -17,6 +18,10 @@ export default function BookmarkList() { Bookmark | undefined >(); const [modalLoading, setModalLoading] = useState(false); + + // 시간확인 확인 모달 상태 + const [confirmModalOpen, setConfirmModalOpen] = useState(false); + const [selectedBookmark, setSelectedBookmark] = useState(null); // 초기 데이터 로딩 useEffect(() => { @@ -62,6 +67,29 @@ export default function BookmarkList() { } }; + // 북마크 시간 확인 (확인 모달 표시) + 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); @@ -144,32 +172,18 @@ export default function BookmarkList() {
{/* 북마크 목록 */} - {bookmarks.length === 0 ? ( -
-
📚
-

- 아직 북마크가 없습니다 -

-

첫 번째 북마크를 추가해보세요!

- -
- ) : ( -
- {bookmarks.map((bookmark) => ( - - ))} -
- )} +
+ {bookmarks.map((bookmark) => ( + + ))} +
{/* 북마크 추가/수정 모달 */} + + {/* 시간확인 확인 모달 */} + { + setConfirmModalOpen(false); + setSelectedBookmark(null); + }} + />
); } diff --git a/src/components/bookmarks/BookmarkModal.tsx b/src/components/bookmarks/BookmarkModal.tsx index 3208c2a..66f7834 100644 --- a/src/components/bookmarks/BookmarkModal.tsx +++ b/src/components/bookmarks/BookmarkModal.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Bookmark, BookmarkFormData } from '@/types/bookmark'; -import BookmarkForm from './BookmarkForm'; interface BookmarkModalProps { isOpen: boolean; @@ -12,6 +11,7 @@ interface BookmarkModalProps { isLoading?: boolean; } + export default function BookmarkModal({ isOpen, onClose, @@ -19,6 +19,30 @@ export default function BookmarkModal({ 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) => { @@ -36,58 +60,154 @@ export default function BookmarkModal({ }; }, [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/libs/api/bookmarks.ts b/src/libs/api/bookmarks.ts index 6824095..368e16f 100644 --- a/src/libs/api/bookmarks.ts +++ b/src/libs/api/bookmarks.ts @@ -5,12 +5,12 @@ import { } from '@/types/bookmark'; import { AuthUtils } from '@/libs/auth'; -const API_BASE_URL = 'http://localhost:3001/api'; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE; export class BookmarkAPI { // 북마크 목록 조회 static async getBookmarks(): Promise { - const response = await fetch(`${API_BASE_URL}/bookmarks`, { + const response = await fetch(`${API_BASE_URL}/api/bookmarks`, { headers: AuthUtils.getAuthHeaders(), }); @@ -43,9 +43,12 @@ export class BookmarkAPI { static async createBookmark(data: BookmarkCreateRequest): Promise { console.log('북마크 추가 요청 데이터:', data); - const response = await fetch(`${API_BASE_URL}/bookmarks`, { + const response = await fetch(`${API_BASE_URL}/api/bookmarks`, { method: 'POST', - headers: AuthUtils.getAuthHeaders(), + headers: { + ...AuthUtils.getAuthHeaders(), + 'Content-Type': 'application/json', + }, body: JSON.stringify(data), }); @@ -89,9 +92,14 @@ export class BookmarkAPI { id: number, data: BookmarkUpdateRequest, ): Promise { - const response = await fetch(`${API_BASE_URL}/bookmarks/${id}`, { + console.log('북마크 수정 요청 데이터:', data); + + const response = await fetch(`${API_BASE_URL}/api/bookmarks/${id}`, { method: 'PUT', - headers: AuthUtils.getAuthHeaders(), + headers: { + ...AuthUtils.getAuthHeaders(), + 'Content-Type': 'application/json', + }, body: JSON.stringify(data), }); @@ -100,27 +108,53 @@ export class BookmarkAPI { } if (!response.ok) { - throw new Error('북마크 수정에 실패했습니다.'); + 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 && - Array.isArray(result.data) && - result.data.length > 0 - ) { - return result.data[0]; + // 다양한 응답 형식 처리 + 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}/bookmarks/${id}`, { + const response = await fetch(`${API_BASE_URL}/api/bookmarks/${id}`, { method: 'DELETE', headers: AuthUtils.getAuthHeaders(), }); @@ -143,7 +177,7 @@ export class BookmarkAPI { // 북마크 클릭 (조회수 증가) static async clickBookmark(id: number): Promise { - const response = await fetch(`${API_BASE_URL}/bookmarks/${id}/click`, { + const response = await fetch(`${API_BASE_URL}/api/bookmarks/${id}/click`, { headers: AuthUtils.getAuthHeaders(), }); diff --git a/src/types/bookmark.ts b/src/types/bookmark.ts index 6462fe0..c628985 100644 --- a/src/types/bookmark.ts +++ b/src/types/bookmark.ts @@ -12,6 +12,7 @@ export interface Bookmark { export interface BookmarkCreateRequest { custom_name: string; custom_url: string; + favicon?: string; // 선택적 필드로 유지 (백엔드에서 사용) } export interface BookmarkUpdateRequest { From 04a4a8198d4e4cde17e634d5f5f24c273cd188d5 Mon Sep 17 00:00:00 2001 From: hannah0352 Date: Sat, 20 Sep 2025 00:26:27 +0900 Subject: [PATCH 3/4] =?UTF-8?q?design:=20=ED=97=A4=EB=8D=94=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=99=88=20=EB=A7=81=ED=81=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/bookmarks/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/bookmarks/page.tsx b/src/app/bookmarks/page.tsx index 2517f8b..99cb457 100644 --- a/src/app/bookmarks/page.tsx +++ b/src/app/bookmarks/page.tsx @@ -181,7 +181,6 @@ export default function BookmarksPage() {