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
43 changes: 43 additions & 0 deletions src/api/books/getSavedBooksInMy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { apiClient } from '../index';

// 개인적으로 저장한 책 정보 타입
export interface SavedBookInMy {
bookId: number;
bookTitle: string;
authorName: string;
publisher: string;
bookImageUrl: string;
isbn: string;
isSaved: boolean;
}

// API 응답 데이터 타입
export interface SavedBooksInMyData {
bookList: SavedBookInMy[];
}

// API 응답 타입
export interface SavedBooksInMyResponse {
isSuccess: boolean;
code: number;
message: string;
data: SavedBooksInMyData;
}

// 개인적으로 저장한 책 목록 조회 API 함수
export const getSavedBooksInMy = async () => {
try {
const response = await apiClient.get<SavedBooksInMyResponse>('/books/saved');
return response.data;
} catch (error) {
console.error('개인 저장 책 조회 API 오류:', error);
throw error;
}
};

/*
// 사용 예시
const savedBooksInMy = await getSavedBooksInMy();
console.log(savedBooksInMy.data.bookList); // 개인 저장 책 목록
console.log(savedBooksInMy.data.bookList[0].bookTitle); // 첫 번째 책 제목
*/
68 changes: 68 additions & 0 deletions src/api/feeds/getSavedFeedsInMy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { apiClient } from '../index';

// 개인적으로 저장한 피드 정보 타입
export interface SavedFeedInMy {
feedId: number;
creatorId: number;
creatorNickname: string;
creatorProfileImageUrl: string;
aliasName: string;
aliasColor: string;
postDate: string;
isbn: string;
bookTitle: string;
bookAuthor: string;
contentBody: string;
contentUrls: string[];
likeCount: number;
commentCount: number;
isSaved: boolean;
isLiked: boolean;
isWriter: boolean;
}

// API 응답 데이터 타입 (무한스크롤 지원)
export interface SavedFeedsInMyData {
feedList: SavedFeedInMy[];
nextCursor: string;
isLast: boolean;
}

// API 응답 타입
export interface SavedFeedsInMyResponse {
isSuccess: boolean;
code: number;
message: string;
data: SavedFeedsInMyData;
}

// 개인적으로 저장한 피드 목록 조회 API 함수 (무한스크롤)
export const getSavedFeedsInMy = async (cursor: string | null = null) => {
try {
const params: { cursor?: string | null } = {};
if (cursor !== null) {
params.cursor = cursor;
}

const response = await apiClient.get<SavedFeedsInMyResponse>('/feeds/saved', {
params,
});
return response.data;
} catch (error) {
console.error('개인 저장 피드 조회 API 오류:', error);
throw error;
}
};

/*
// 사용 예시 (무한스크롤)
const savedFeedsInMy = await getSavedFeedsInMy();
console.log(savedFeedsInMy.data.feedList); // 저장된 피드 목록
console.log(savedFeedsInMy.data.nextCursor); // 다음 페이지 커서
console.log(savedFeedsInMy.data.isLast); // 마지막 페이지 여부

// 다음 페이지 로드
if (!savedFeedsInMy.data.isLast) {
const nextPage = await getSavedFeedsInMy(savedFeedsInMy.data.nextCursor);
}
*/
2 changes: 1 addition & 1 deletion src/components/common/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ interface LoadingSpinnerProps {
}

const LoadingSpinner = ({
message = '로딩 중...',
message = '',
size = 'medium',
fullHeight = false,
}: LoadingSpinnerProps) => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/feed/MyFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getMyProfile } from '@/api/feeds/getMyProfile';
import type { MyProfileData } from '@/types/profile';
import LoadingSpinner from '@/components/common/LoadingSpinner';

const MyFeed = ({ showHeader, posts = [], isMyFeed, isLast = false }: FeedListProps) => {
const MyFeed = ({ showHeader, posts = [], isLast = false }: FeedListProps) => {
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Props 타입을 좁혀 불필요한 prop 전달을 방지하세요

공개 API에서 isMyFeed를 제거했지만, 현재 시그니처가 여전히 FeedListProps를 사용하고 있어 호출부가 isMyFeed를 넘겨도 TS에서 잡히지 않고 컴포넌트 내부에서 무시됩니다. 혼란 방지를 위해 타입을 좁히는 것을 권장합니다.

-const MyFeed = ({ showHeader, posts = [], isLast = false }: FeedListProps) => {
+const MyFeed = (
+  { showHeader, posts = [], isLast = false }: Omit<FeedListProps, 'isMyFeed'>
+) => {

호출부에 남아있는 isMyFeed 전달을 점검하려면 아래 스크립트를 실행해 주세요.


🏁 Script executed:

#!/bin/bash
# MyFeed에 isMyFeed prop을 전달하는 사용처 탐지
rg -nP --type=tsx -C2 '<MyFeed[^>]*\bisMyFeed\s*='

Length of output: 80


아래 스크립트로 isMyFeed prop 전달 여부를 다시 확인해 주세요.


🏁 Script executed:

#!/bin/bash
# MyFeed에 isMyFeed prop 전달 여부 확인
rg -nP -C2 "<MyFeed[^>]*\bisMyFeed\s*=" -g '*.tsx'

Length of output: 330


Props 타입 좁히기 및 호출부 isMyFeed 제거

MyFeed 컴포넌트에서 더 이상 사용하지 않는 isMyFeed prop을 타입에서 제외하고, 호출부에서도 관련 전달을 제거하세요.

문제 요약

  • 공개 API에서 isMyFeed를 삭제했음에도 여전히 FeedListProps에 포함되어 있어, 호출부에서 넘겨도 TS가 잡아내지 않고 무시됩니다.
  • 컴포넌트 시그니처를 좁히면 불필요한 prop 전달을 컴파일 단계에서 차단할 수 있습니다.

수정할 위치

  • src/components/feed/MyFeed.tsx
  • src/pages/feed/Feed.tsx (라인 185)

제안하는 변경사항

  1. MyFeed 시그니처 수정 (타입에서 isMyFeed 제외)

    -const MyFeed = ({ showHeader, posts = [], isLast = false }: FeedListProps) => {
    +const MyFeed = (
    +  { showHeader, posts = [], isLast = false }: Omit<FeedListProps, 'isMyFeed'>
    +) => {
  2. 호출부에서 isMyFeed prop 제거

    -<MyFeed showHeader={false} posts={myFeedPosts} isMyFeed={true} isLast={myIsLast} />
    +<MyFeed showHeader={false} posts={myFeedPosts} isLast={myIsLast} />
📝 Committable suggestion

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

Suggested change
const MyFeed = ({ showHeader, posts = [], isLast = false }: FeedListProps) => {
// In src/components/feed/MyFeed.tsx
const MyFeed = (
{ showHeader, posts = [], isLast = false }: Omit<FeedListProps, 'isMyFeed'>
) => {
🤖 Prompt for AI Agents
In src/components/feed/MyFeed.tsx around line 12 and in src/pages/feed/Feed.tsx
at line 185, remove the unused isMyFeed property from the FeedListProps type and
from the MyFeed component signature so the prop is no longer accepted; then
update the caller in Feed.tsx (line 185) to stop passing isMyFeed. Specifically,
narrow the FeedListProps interface/type to exclude isMyFeed, change the MyFeed
parameter list to only accept the actual props (showHeader, posts, isLast), and
delete the isMyFeed argument from the MyFeed JSX invocation in Feed.tsx.

const [profileData, setProfileData] = useState<MyProfileData | null>(null);
const [loading, setLoading] = useState(true);

Expand Down Expand Up @@ -55,7 +55,7 @@ const MyFeed = ({ showHeader, posts = [], isMyFeed, isLast = false }: FeedListPr
<FeedPost
key={`${post.feedId}-${index}`}
showHeader={showHeader}
isMyFeed={isMyFeed}
isMyFeed={true}
isLast={isLast && index === posts.length - 1}
{...post}
/>
Expand Down
18 changes: 13 additions & 5 deletions src/pages/mypage/Mypage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,20 @@ const Mypage = () => {
};

const handleService = () => {
window.open('https://slashpage.com/thip/xjqy1g2vw7vejm6vd54z', '_blank');
window.open('https://slashpage.com/thip/dk58wg2e6yy3zmnqevxz', '_blank');
};
Comment on lines 76 to +78
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

보안: window.open('_blank') 사용 시 reverse tabnabbing 방어 추가 필요

외부 링크를 새 탭으로 열 때는 opener 참조 차단이 필요합니다. 현재 구현은 탭 하이재킹 위험이 있습니다. noopener,noreferrer를 사용하거나, 공용 헬퍼로 처리해 주세요.

아래처럼 교체하는 것을 권장합니다.

-  const handleService = () => {
-    window.open('https://slashpage.com/thip/dk58wg2e6yy3zmnqevxz', '_blank');
-  };
+  const handleService = () => {
+    openExternal('https://slashpage.com/thip/dk58wg2e6yy3zmnqevxz');
+  };

-  const handleTerms = () => {
-    window.open('https://slashpage.com/thip/7916x82r8y74n24kpyg3', '_blank');
-  };
+  const handleTerms = () => {
+    openExternal('https://slashpage.com/thip/7916x82r8y74n24kpyg3');
+  };

-  const handleVersion = () => {
-    window.open('https://slashpage.com/thip/1q3vdn2p9w93pmxy49pr', '_blank');
-  };
+  const handleVersion = () => {
+    openExternal('https://slashpage.com/thip/1q3vdn2p9w93pmxy49pr');
+  };

헬퍼 예시(이 파일 상단 또는 공용 유틸에 추가):

// utils/window.ts (예시)
export const openExternal = (url: string) => {
  const w = window.open(url, '_blank', 'noopener,noreferrer');
  if (w) w.opener = null;
};

또한, 동일한 형태의 외부 링크가 늘어나는 추세라면 URL들을 상수로 중앙집중화 하는 것도 유지보수에 유리합니다. 예: src/constants/externalLinks.ts에 보관.

Also applies to: 80-83, 84-86

🤖 Prompt for AI Agents
In src/pages/mypage/Mypage.tsx around lines 76-78 (and similarly at 80-83 and
84-86), the component opens external links with window.open(url, '_blank') which
is vulnerable to reverse tabnabbing; replace these direct calls with a safe
helper that opens the URL using window.open(url, '_blank',
'noopener,noreferrer') and then sets the returned window's opener to null if
present, and update the call sites to use this helper; additionally consider
centralizing external URLs into a constants file (e.g.,
src/constants/externalLinks.ts) and reference those constants from the
component.


const handleTerms = () => {
window.open('https://slashpage.com/thip/7916x82r8y74n24kpyg3', '_blank');
};

const handleVersion = () => {
window.open('https://slashpage.com/thip/1q3vdn2p9w93pmxy49pr', '_blank');
};

return (
<Wrapper>
<Header>마이페이지</Header>
<Header>내 정보</Header>
<Container>
<UserProfile>
<div className="userInfo">
Expand All @@ -106,15 +114,15 @@ const Mypage = () => {
<MenuGrid>
<MenuButton src={alert} name="알림설정" isButton onClick={handleAlert} />
<MenuButton src={service} name="고객센터" isButton onClick={handleService} />
<MenuButton src={notice} name="공지사항" isButton />
<MenuButton src={notice} name="공지사항" isButton onClick={handleNotice} />
<MenuButton
src={terms}
name="개인정보처리방침 & 이용약관"
isButton
onClick={handleNotice}
onClick={handleTerms}
/>
<MenuButton src={guide} name="가이드" isButton onClick={handleGuide} />
<MenuButton src={ver} name="버젼 1.0" isButton={false} />
<MenuButton src={ver} name="버젼 1.0.0" isButton onClick={handleVersion} />
</MenuGrid>
</Section>
<BottomMenu>
Expand Down
159 changes: 135 additions & 24 deletions src/pages/mypage/SavePage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import TitleHeader from '../../components/common/TitleHeader';
import TabBar from '@/components/feed/TabBar';
import FeedPost from '@/components/feed/FeedPost';
Expand All @@ -8,32 +8,129 @@ import save from '../../assets/feed/save.svg';
import activeSave from '../../assets/feed/activeSave.svg';
import styled from '@emotion/styled';
import { colors, typography } from '@/styles/global/global';
import { mockBooks } from '@/data/bookData';
import { mockPosts } from '@/data/postData';
import { getSavedBooksInMy, type SavedBookInMy } from '@/api/books/getSavedBooksInMy';
import { getSavedFeedsInMy, type SavedFeedInMy } from '@/api/feeds/getSavedFeedsInMy';
import { postSaveBook } from '@/api/books/postSaveBook';
import LoadingSpinner from '@/components/common/LoadingSpinner';

const tabs = ['피드', '책'];

const SavePage = () => {
const hasPosts = mockPosts.length > 0;
const hasBooks = mockBooks.length > 0;

const navigate = useNavigate();
const [activeTab, setActiveTab] = useState(tabs[0]);
const [savedBooks, setSavedBooks] = useState<{ [isbn: string]: boolean }>({});

// 피드 관련 상태
const [savedFeeds, setSavedFeeds] = useState<SavedFeedInMy[]>([]);
const [feedNextCursor, setFeedNextCursor] = useState<string | null>(null);
const [feedIsLast, setFeedIsLast] = useState(false);
const [feedLoading, setFeedLoading] = useState(false);

// 책 관련 상태
const [savedBooks, setSavedBooks] = useState<SavedBookInMy[]>([]);

// 초기 로딩 상태
const [initialLoading, setInitialLoading] = useState(true);

// Intersection Observer ref
const feedObserverRef = useRef<HTMLDivElement>(null);

const handleBack = () => {
navigate('/mypage');
};

// 저장된 피드 로드
const loadSavedFeeds = useCallback(async (cursor: string | null = null) => {
try {
setFeedLoading(true);
const response = await getSavedFeedsInMy(cursor);

if (cursor === null) {
// 첫 로드
setSavedFeeds(response.data.feedList);
} else {
// 추가 로드
setSavedFeeds(prev => [...prev, ...response.data.feedList]);
}

setFeedNextCursor(response.data.nextCursor);
setFeedIsLast(response.data.isLast);
} catch (error) {
console.error('저장된 피드 로드 실패:', error);
} finally {
setFeedLoading(false);
}
}, []);
Comment on lines +41 to +62
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

loadSavedFeeds 중복 요청 방지 및 안정성 개선

IntersectionObserver가 동일 커서로 여러 번 트리거될 여지가 있습니다. 함수 초반에 ref 기반 가드와 락 설정/해제를 추가하면 안정적입니다.

적용 제안:

   const loadSavedFeeds = useCallback(async (cursor: string | null = null) => {
     try {
-      setFeedLoading(true);
+      if (feedLoadingRef.current) return;
+      setFeedLoading(true);
+      feedLoadingRef.current = true;
       const response = await getSavedFeedsInMy(cursor);

       if (cursor === null) {
         // 첫 로드
         setSavedFeeds(response.data.feedList);
       } else {
         // 추가 로드
         setSavedFeeds(prev => [...prev, ...response.data.feedList]);
       }

       setFeedNextCursor(response.data.nextCursor);
       setFeedIsLast(response.data.isLast);
     } catch (error) {
       console.error('저장된 피드 로드 실패:', error);
     } finally {
-      setFeedLoading(false);
+      setFeedLoading(false);
+      feedLoadingRef.current = false;
     }
   }, []);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/pages/mypage/SavePage.tsx around lines 38 to 59, loadSavedFeeds can be
called multiple times for the same cursor by IntersectionObserver; add a
ref-based guard and a loading lock to prevent duplicate/concurrent requests:
create refs (e.g., currentCursorRef and isLoadingRef), at the start return early
if isLoadingRef.current is true or if cursor === currentCursorRef.current, then
set isLoadingRef.current = true and currentCursorRef.current = cursor before
calling getSavedFeedsInMy, and in the finally block set isLoadingRef.current =
false (and if cursor is null also reset currentCursorRef.current appropriately);
ensure setFeedLoading remains for UI but the refs control request concurrency
and duplicate cursor calls.


// 페이지 진입 시 모든 데이터 로드
useEffect(() => {
const loadAllData = async () => {
try {
setInitialLoading(true);

// 두 API를 병렬로 호출
const [feedsResponse, booksResponse] = await Promise.all([
getSavedFeedsInMy(null),
getSavedBooksInMy(),
]);

// 피드 데이터 설정
setSavedFeeds(feedsResponse.data.feedList);
setFeedNextCursor(feedsResponse.data.nextCursor);
setFeedIsLast(feedsResponse.data.isLast);

// 책 데이터 설정
setSavedBooks(booksResponse.data.bookList);
} catch (error) {
Comment on lines +71 to +83
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

초기 데이터 병렬 로드에서도 반환 타입 오해로 인한 접근 버그

getSavedFeedsInMy/getSavedBooksInMy 모두 data 자체를 반환합니다. *.data.* 접근을 제거해야 합니다.

-        const [feedsResponse, booksResponse] = await Promise.all([
+        const [feedsData, booksData] = await Promise.all([
           getSavedFeedsInMy(null),
           getSavedBooksInMy(),
         ]);

         // 피드 데이터 설정
-        setSavedFeeds(feedsResponse.data.feedList);
-        setFeedNextCursor(feedsResponse.data.nextCursor);
-        setFeedIsLast(feedsResponse.data.isLast);
+        setSavedFeeds(feedsData.feedList);
+        setFeedNextCursor(feedsData.nextCursor);
+        setFeedIsLast(feedsData.isLast);

         // 책 데이터 설정
-        setSavedBooks(booksResponse.data.bookList);
+        setSavedBooks(booksData.bookList);

추가 제안: 한쪽 API 실패 시 다른 데이터는 보여주고 싶다면 Promise.all 대신 allSettled로 부분 성공을 허용하는 패턴을 고려하세요. 필요하시면 패치 제안 드리겠습니다.

📝 Committable suggestion

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

Suggested change
const [feedsResponse, booksResponse] = await Promise.all([
getSavedFeedsInMy(null),
getSavedBooksInMy(),
]);
// 피드 데이터 설정
setSavedFeeds(feedsResponse.data.feedList);
setFeedNextCursor(feedsResponse.data.nextCursor);
setFeedIsLast(feedsResponse.data.isLast);
// 책 데이터 설정
setSavedBooks(booksResponse.data.bookList);
} catch (error) {
const [feedsData, booksData] = await Promise.all([
getSavedFeedsInMy(null),
getSavedBooksInMy(),
]);
// 피드 데이터 설정
setSavedFeeds(feedsData.feedList);
setFeedNextCursor(feedsData.nextCursor);
setFeedIsLast(feedsData.isLast);
// 책 데이터 설정
setSavedBooks(booksData.bookList);
} catch (error) {
🤖 Prompt for AI Agents
In src/pages/mypage/SavePage.tsx around lines 71 to 83, the code incorrectly
assumes the API helpers return an object with a data field and uses
feedsResponse.data.feedList / booksResponse.data.bookList; both
getSavedFeedsInMy and getSavedBooksInMy return the data directly, so remove the
extra `.data` access and use feedsResponse.feedList / feedsResponse.nextCursor /
feedsResponse.isLast and booksResponse.bookList instead; optionally, if you want
partial results when one API fails, replace Promise.all with Promise.allSettled
and handle fulfilled/rejected cases accordingly.

console.error('초기 데이터 로드 실패:', error);
} finally {
setInitialLoading(false);
}
};

loadAllData();
}, []); // 한 번만 실행

// Intersection Observer 설정 (피드)
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !feedIsLast && !feedLoading && feedNextCursor) {
loadSavedFeeds(feedNextCursor);
}
});
},
{ threshold: 0.1 },
);

if (feedObserverRef.current) {
observer.observe(feedObserverRef.current);
}

return () => observer.disconnect();
}, [feedIsLast, feedLoading, feedNextCursor, loadSavedFeeds]);

useEffect(() => {
window.scrollTo(0, 0);
}, [activeTab]);

const handleSaveToggle = (isbn: string) => {
setSavedBooks(prev => ({
...prev,
[isbn]: !prev[isbn],
}));
const handleSaveToggle = async (isbn: string) => {
try {
const currentBook = savedBooks.find(book => book.isbn === isbn);
if (!currentBook) return;

const newSaveState = !currentBook.isSaved;
await postSaveBook(isbn, newSaveState);

// 로컬 상태 업데이트
setSavedBooks(prev =>
prev.map(book => (book.isbn === isbn ? { ...book, isSaved: newSaveState } : book)),
);

console.log('저장 토글:', isbn, newSaveState);
} catch (error) {
console.error('저장 토글 실패:', error);
}
};

return (
Expand All @@ -44,13 +141,27 @@ const SavePage = () => {
title="저장"
/>
<TabBar tabs={tabs} activeTab={activeTab} onTabClick={setActiveTab} />
{activeTab === '피드' ? (
{initialLoading ? (
<LoadingSpinner fullHeight={true} size="large" />
) : activeTab === '피드' ? (
<>
{hasPosts ? (
{savedFeeds.length > 0 ? (
<FeedContainer>
{mockPosts.map(post => (
<FeedPost key={post.feedId} showHeader={true} isMyFeed={false} {...post} />
{savedFeeds.map((feed, index) => (
<FeedPost
key={feed.feedId}
showHeader={true}
isMyFeed={false}
isLast={index === savedFeeds.length - 1}
{...feed}
/>
))}
{/* 무한스크롤을 위한 observer 요소 */}
{!feedIsLast && (
<div ref={feedObserverRef} style={{ height: '20px' }}>
{feedLoading && <LoadingSpinner fullHeight={false} size="small" />}
</div>
)}
</FeedContainer>
) : (
<EmptyState>
Expand All @@ -59,24 +170,24 @@ const SavePage = () => {
</EmptyState>
)}
</>
) : hasBooks ? (
) : savedBooks.length > 0 ? (
<>
<BookList>
{mockBooks.map(book => (
<BookItem key={book.isbn}>
{savedBooks.map(book => (
<BookItem key={book.bookId}>
<LeftSection>
<Cover src={book.coverUrl} alt={`${book.title} 커버`} />
<Cover src={book.bookImageUrl} alt={`${book.bookTitle} 커버`} />
<BookInfo>
<Title>{book.title}</Title>
<Title>{book.bookTitle}</Title>
<Subtitle>
{book.author} 저 · {book.publisher}
{book.authorName} 저 · {book.publisher}
</Subtitle>
</BookInfo>
</LeftSection>
<SaveIcon onClick={() => handleSaveToggle(book.isbn)}>
<img
src={savedBooks[book.isbn] ? activeSave : save}
alt={savedBooks[book.isbn] ? '저장됨' : '저장'}
src={book.isSaved ? activeSave : save}
alt={book.isSaved ? '저장됨' : '저장'}
/>
</SaveIcon>
</BookItem>
Expand Down