session Storage하면 탭 바뀌거나 다른 창에서 데이터 연결이 안됨
그래서 local Storage 씀
주의할 점은 local Storage는 string 받는데, 이 때문에 JSON => str로 바꾸고
str => JSON으로 받는 거 필요
물론 local Storage가 너무 쉽게 접근 및 수정이 가능해서 보안 이슈 주의
또, 쉽게 데이터 관리할 수 있게 zustand 같은 라이브러리 씀.
간편하게 전역변수 관리 가능해서 Context 의존도 줄음
사실 zustand 안에 Context API 쓰는 거여서 편리
middle ware 써서 local Storage 같은 곳에 저장하면 좋음
umcine-bookmark-store 저장값을 확인하고, 저장값을 지웠을 때 초기 빈 상태로 돌아오는지 확인한 뒤 pnpm build를 실행해요.sessionStorage로 저장소를 바꾸고 탭을 닫았을 때의 차이를 기록해 보세요.//bookmark-button.tsx
import { useBookmarkStore } from "../stores/bookmark-store";
import { cn } from "../utils/cn";
interface BookmarkButtonProps {
movieId: number;
}
export function BookmarkButton({ movieId }: BookmarkButtonProps) {
const isBookmarked = useBookmarkStore((state) =>
state.bookmarkedMovieIds.includes(movieId),
);
//큰 state 안에 값 접근
const toggleBookmark = useBookmarkStore(
(state) => state.toggleBookmark,
);
return (<button
type="button"
onClick={() => toggleBookmark(movieId)}
aria-pressed={isBookmarked}
aria-label={isBookmarked ? "북마크 해제" : "북마크하기"}
className={cn(
"absolute right-2 top-2 rounded-md border p-1",
isBookmarked ? "border-brand bg-brand" : "border-white bg-black/60",
)}>
<img src={isBookmarked ? "/icons/bookmark.svg" : "/icons/bookmark-outline.svg"} alt="" className="size-5 invert" />
</button>);
// return (
// <button type="button" onClick={() => toggleBookmark(movieId)}>
// {isBookmarked ? "북마크 해제" : "북마크 추가"}
// </button>
// );
}
export function BookmarkButtonDetailPage({ movieId }: BookmarkButtonProps) {
const isBookmarked = useBookmarkStore((state) =>
state.bookmarkedMovieIds.includes(movieId),
);
//큰 state 안에 값 접근
const toggleBookmark = useBookmarkStore(
(state) => state.toggleBookmark,
);
return (<button
type="button"
onClick={() => toggleBookmark(movieId)}
aria-pressed={isBookmarked}
className="mt-4 flex items-center gap-2 rounded-lg bg-brand px-4 py-2 text-sm font-bold text-white"
>
<img
src={isBookmarked ? "/icons/bookmark.svg" : "/icons/bookmark-outline.svg"}
alt=""
className="size-4 invert"
/>
{isBookmarked ? "즐겨찾기 해제" : "즐겨찾기"}
</button>);
}
//movie-card.tsx
import { type Movie } from "../../types/movie";
import { Link } from "@tanstack/react-router";
import { BookmarkButton } from "../bookmark-button";
type MovieCardProps = {
movie : Movie
}
export default function MovieCard({movie} : MovieCardProps) {
return (
<article className="relative min-w-0">
<Link to="/movies/$movieId" params={{movieId : String(movie.id)}}>
<img className="block aspect-[221/250] w-full rounded-xl object-cover"
src={movie.posterPath}
alt={`${movie.title} 포스터`} />
<h3 className="mt-2 truncate text-sm font-bold">{movie.title}</h3>
<p className="text-xs text-gray-400">{movie.releaseDate}</p>
</Link>
<BookmarkButton movieId={movie.id} />
</article>
)
}
//movie-detail-page.tsx
import { Link, useParams } from "@tanstack/react-router";
import { useContext } from "react";
import { MovieContext } from "../../contexts/movie-context";
import { BookmarkButtonDetailPage } from "../../components/bookmark-button";
export function MovieDetailPage() {
const {movieId} = useParams({from: "/movies/$movieId"});
const {movies} = useContext(MovieContext);
const movie = movies.find((m) => m.id === Number(movieId));
if (!movie) {
return (
<main className="mx-auto w-full max-w-[1170px] flex-1 py-10">
<p>해당하는 영화를 찾을 수 없어요.</p>
<Link to="/" className="text-brand underline">영화 목록</Link>
</main>
)
}
return (
<main className="flex-1">
<section className="relative h-[330px] text-white">
<img src={movie.backdropPath} alt="" aria-hidden="true" className="absolute inset-0 size-full object-cover" />
{/* aria-hidden:true 단순 꾸미기 용도. 스크린 리더는 이거 건너뛰셈 */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-black/10" />
<div className="relative mx-auto flex h-full max-w-[1170px] flex-col justify-between py-6">
<Link to="/" className="flex w-fit items-center gap-1 text-xs font-bold">
<img src="/icons/chevron-left.svg" alt="" className="size-4 invert" />
영화 목록
</Link>
<div>
<h1 className="text-5xl font-extrabold">{movie.title}</h1>
<p className="mt-2 text-sm">{movie.originalTitle}</p>
<p className="mt-1 text-xs font-bold">
{movie.releaseDate} <span className="ml-1">{movie.genres.join(" · ")}</span> <span className="ml-1">{movie.runtime}</span>
</p>
</div>
</div>
</section>
<div className="mx-auto grid max-w-[1170px] gap-8 py-5 md:grid-cols-[183px_1fr]"> {/*_300px*/}
<img
src={movie.posterPath}
alt={`${movie.title} 포스터`}
className="w-[183px] rounded-lg shadow-lg"
/>
<div>
<h2 className="text-xl font-bold">{movie.tagline}</h2>
<p className="mt-3 text-sm leading-relaxed text-gray-600">{movie.overview}</p>
<BookmarkButtonDetailPage movieId={movie.id}/>
</div>
{/* <div className="md:border-l md:border-gray-200 md:pl-6">
</div> */}
</div>
</main>
);
}
//bookmark-store.ts
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
interface BookmarkStore {
bookmarkedMovieIds: number[];
toggleBookmark: (movieId: number) => void;
}
export const useBookmarkStore = create<BookmarkStore>()(
persist(
(set) => ({
bookmarkedMovieIds: [],
toggleBookmark: (movieId) =>
set((state) => ({
bookmarkedMovieIds: state.bookmarkedMovieIds.includes(movieId)
? state.bookmarkedMovieIds.filter((id) => id !== movieId)
: [...state.bookmarkedMovieIds, movieId],
})),
}),
{
name: "umcine-bookmark-store", //저장 key
storage: createJSONStorage(() => localStorage), //저장 위치
partialize: (state) => ({
bookmarkedMovieIds: state.bookmarkedMovieIds, //저장할꺼
}),
},
),
);
현재 페이지(footer의 page selector)를 저장함
PageProvider 지우고 zustand로 변경하면서 기본 값만 이용
위 필수미션의 코드 + 밑 내용
//movie-grid.tsx
import { useContext } from "react";
import { MovieContext } from "../../contexts/movie-context";
import MovieCard from "./movie-card";
import { PAGE_SIZE } from "../../contexts/page-bar-context";
import { usePageBarStore } from "../../stores/page-bar-store";
export default function MovieGrid() {
const {movies} = useContext(MovieContext);
const page = usePageBarStore((state) =>
state.currentPage,
);
const targetMovies = movies.slice((page-1)*PAGE_SIZE, page*PAGE_SIZE);
return (
<div className="grid grid-cols-2 gap-4 gap-y-6 md:grid-cols-3 lg:grid-cols-5">
{targetMovies.length === 0
? <p>영화가 없습니다</p>
: targetMovies.map((movie) => (
<MovieCard key={movie.id} movie={movie}/>
))
}
</div>
);
}
//page-selector.tsx
import { PageBarButton } from "../page-bar-button";
import { possiblePages } from "../../contexts/page-bar-context";
export function PageSelector() {
return (
<nav aria-label="페이지" className="mt-8 flex justify-center gap-2">
{possiblePages.map((pageNum) => (
<PageBarButton pageNum={pageNum}/>
))}
</nav>
);
}
//page-bar-button.tsx
import { usePageBarStore } from "../stores/page-bar-store";
import { cn } from "../utils/cn";
interface PageBarButtonProps {
pageNum: number;
}
export function PageBarButton({ pageNum }: PageBarButtonProps) {
const currentPage = usePageBarStore(
(state) => state.currentPage,
);
const setPage = usePageBarStore(
(state) => state.setPage,
);
return (<button
key={pageNum}
onClick={() => {setPage(pageNum)}}
aria-current={currentPage === pageNum ? "page" : undefined}
className={cn(
"size-9 rounded-lg border text-sm font-bold",
currentPage === pageNum
? "border-brand bg-brand text-white"
: "border-gray-200 bg-white text-gray-600",
)}>
{pageNum}
</button>);
}
//page-context.tsx
import { movies } from "../data/movies";
export const PAGE_SIZE = 5;
export const initialPage: number = 1;
export const possiblePages: number[] = Array.from(
{ length: Math.ceil(movies.length / PAGE_SIZE) },
(_, i) => i + 1, //(현재 값, index) => 값
);
얘가 context 관리자였는데 이젠 필요가 없어져서 고민
기본 변수로 쓰이는데 따로 폴더 빼서 처리하는 걸로 해야할 듯
//page-bar-store.ts
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { initialPage } from "../contexts/page-bar-context";
interface PageBarStore {
currentPage: number;
setPage: (page: number) => void;
}
export const usePageBarStore = create<PageBarStore>()(
persist(
(set) => ({
currentPage: initialPage, //기본값
setPage: (page) =>
set((_) => ({
currentPage: page,
})),
}),
{
name: "umcine-page-bar-store", //저장 key
storage: createJSONStorage(() => localStorage), //저장 위치
partialize: (state) => ({
currentPage: state.currentPage, //저장할꺼
}),
},
),
);
//pagination.tsx
import MovieGrid from "./movie-grid";
// import { PageProvider } from "../../contexts/page-bar-context";
import { PageSelector } from "../page-bar/page-bar-selector";
export default function Pagination() {
return (
<>
<h1 className="mb-4 text-4xl font-extrabold">영화 목록</h1>
<MovieGrid />
<PageSelector />
</>
);
}