feat: integrate Zustand for global state management;

This commit is contained in:
Norbert Maciaszek
2025-11-09 11:10:27 +01:00
parent d6ca9e1429
commit fede32d150
17 changed files with 333 additions and 335 deletions

36
package-lock.json generated
View File

@@ -18,7 +18,8 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"swr": "^2.3.6"
"swr": "^2.3.6",
"zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1593,7 +1594,7 @@
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1703,7 +1704,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
@@ -2770,6 +2771,35 @@
"engines": {
"node": ">=18"
}
},
"node_modules/zustand": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@@ -19,7 +19,8 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-icons": "^5.5.0",
"swr": "^2.3.6"
"swr": "^2.3.6",
"zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -1,10 +1,10 @@
import { MovieCard } from "@/components/atoms/MovieCard";
import { ActorHero } from "@/components/molecules/ActorHero";
import { Carousel } from "@/components/molecules/Carousel";
import { Gallery } from "@/components/molecules/Gallery";
import { convertToMovie } from "@/helpers/convertToMovie";
import { TMDB } from "@/lib/tmdb";
import { FaStar } from "react-icons/fa";
import { MovieCard } from '@/components/atoms/MovieCard';
import { ActorHero } from '@/components/molecules/ActorHero';
import { Carousel } from '@/components/molecules/Carousel';
import { Gallery } from '@/components/molecules/Gallery';
import { convertToMovie } from '@/helpers/convertToMovie';
import { TMDB } from '@/lib/tmdb';
import { FaStar } from 'react-icons/fa';
export default async function Page({
params,
@@ -32,7 +32,7 @@ export default async function Page({
new Date(b.release_date).getTime() -
new Date(a.release_date).getTime()
)
.map((movie) => {
.map(movie => {
const convertedMovie = convertToMovie(movie);
if (!convertedMovie) return null;
return <MovieCard key={movie.id} {...convertedMovie} />;

View File

@@ -1,9 +0,0 @@
import { DB_getMovies } from '@/lib/db/pb';
import { NextResponse } from 'next/server';
export const GET = async () => {
const movies = await DB_getMovies();
const res = NextResponse.json(movies);
return res;
};

View File

@@ -2,8 +2,8 @@ import './globals.css';
import { Navbar } from '@/components/organisms/Navbar';
import { AuroraBackground } from '@/components/effects';
import { GlobalStoreProvider } from './store/globalStore';
import { DB_getMovies } from '@/lib/db/pb';
import { GlobalProvider } from './store/global';
export default async function RootLayout({
children,
@@ -23,11 +23,11 @@ export default async function RootLayout({
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body className={`antialiased`}>
<GlobalStoreProvider initialMovies={movies}>
<GlobalProvider initialMovies={movies}>
<AuroraBackground />
<Navbar />
<main className="relative [&>*:last-child]:pb-16">{children}</main>
</GlobalStoreProvider>
</GlobalProvider>
</body>
</html>
);

View File

@@ -7,7 +7,7 @@ export default async function Home() {
return (
<>
<TrackedMovies />
<MovieList heading="Moja lista" />
<MovieList heading="Moja lista" showSearch />
<RandomMovie heading="Ciężko wybrać?" />
<GenreList heading="Odkrywaj nowe filmy według gatunku" />
</>

107
src/app/store/global.tsx Normal file
View File

@@ -0,0 +1,107 @@
'use client';
import { Spinner } from '@/components/atoms/Spinner';
import { DB_addMovie, DB_deleteMovie, DB_updateMovie } from '@/lib/db/pb';
import {
createContext,
FC,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { create, useStore } from 'zustand';
import { persist } from 'zustand/middleware';
type Props = {
initialMovies: Movie[];
};
type State = {
movies: Movie[];
setMovies: (movies: Movie[]) => void;
addMovie: (movie: Movie) => void;
deleteMovie: (id: number) => void;
updateMovie: (id: number, movie: Partial<Movie>) => void;
displayType: 'grid' | 'list';
setDisplayType: (type: 'grid' | 'list') => void;
};
type Store = ReturnType<typeof createStore>;
const createStore = ({ initialMovies }: Props) => {
return create<State>()(
persist(
(set, get) => ({
movies: initialMovies,
setMovies: (movies: Movie[]) => set({ movies }),
addMovie: (movie: Movie) => {
if (get().movies.find(m => m.id == movie.id)) return;
DB_addMovie(movie);
set(state => ({ movies: [...state.movies, movie] }));
},
deleteMovie: (id: number) => {
DB_deleteMovie(id);
set(state => ({
movies: state.movies.filter(m => m.id != id),
}));
},
updateMovie: (id: number, movie: Partial<Movie>) => {
DB_updateMovie(id, movie);
set(state => ({
movies: state.movies.map(m =>
m.id == id ? { ...m, ...movie } : m
),
}));
},
displayType: 'grid',
setDisplayType: (type: 'grid' | 'list') => set({ displayType: type }),
}),
{
name: 'global',
partialize: state => ({ displayType: state.displayType }),
}
)
);
};
export const GlobalContext = createContext<Store | null>(null);
export const GlobalProvider: FC<
{
children: React.ReactNode;
} & Props
> = ({ children, initialMovies = [] }) => {
const store = useRef(createStore({ initialMovies }));
const [firstRender, setFirstRender] = useState(true);
useEffect(() => {
if (firstRender) {
setFirstRender(false);
}
}, [firstRender]);
if (firstRender) {
return (
<div className="flex justify-center items-center h-screen bg-black/80">
<Spinner />
</div>
);
}
return (
<GlobalContext.Provider value={store.current}>
{children}
</GlobalContext.Provider>
);
};
export const useGlobalStore = <T,>(selector: (state: State) => T): T => {
const store = useContext(GlobalContext);
if (!store) throw new Error('GlobalStore not found');
return useStore(store, selector);
};
export const useGlobalZustandStore = useGlobalStore;

View File

@@ -1,88 +0,0 @@
'use client';
import { Spinner } from '@/components/atoms/Spinner';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { DB_addMovie, DB_deleteMovie, DB_updateMovie } from '@/lib/db/pb';
import { createContext, FC, use, useEffect, useState } from 'react';
type GlobalStore = {
movies: Movie[];
addMovie: (movie: Movie) => void;
deleteMovie: (id: number) => void;
updateMovie: (id: number, movie: Partial<Movie>) => void;
displayType: 'grid' | 'list';
setDisplayType: (type: 'grid' | 'list') => void;
};
const globalStore = createContext<GlobalStore>({
movies: [],
addMovie: () => {},
deleteMovie: () => {},
updateMovie: () => {},
displayType: 'grid',
setDisplayType: () => {},
});
type Props = {
children: React.ReactNode;
initialMovies?: Movie[];
};
export const GlobalStoreProvider: FC<Props> = ({
children,
initialMovies = [],
}) => {
// Optimistic update
const [firstRender, setFirstRender] = useState(true);
const [movies, setMovies] = useState<GlobalStore['movies']>(initialMovies);
const [displayType, setDisplayType] = useLocalStorage<
GlobalStore['displayType']
>('displayType', 'grid');
useEffect(() => {
if (firstRender) {
setFirstRender(false);
}
}, [firstRender]);
const addMovie = async (movie: Movie) => {
if (movies.find(m => m.id === movie.id)) return;
DB_addMovie(movie);
setMovies(prev => [...prev, movie]);
};
const deleteMovie = async (id: number) => {
DB_deleteMovie(id);
setMovies(prev => prev.filter(m => m.id !== id));
};
const updateMovie = async (id: number, movie: Partial<Movie>) => {
DB_updateMovie(id, movie);
setMovies(prev => prev.map(m => (m.id === id ? { ...m, ...movie } : m)));
};
return (
<globalStore.Provider
value={{
movies,
addMovie,
deleteMovie,
updateMovie,
displayType,
setDisplayType,
}}
>
{firstRender ? (
<div className="flex justify-center items-center h-screen bg-black/80">
<Spinner />
</div>
) : (
children
)}
</globalStore.Provider>
);
};
export const useGlobalStore = () => {
return use(globalStore);
};

View File

@@ -1,11 +1,11 @@
'use client';
import { FC } from 'react';
import { useGlobalStore } from '@/app/store/globalStore';
import { FaFire, FaPlusCircle, FaTrash } from 'react-icons/fa';
import Link from 'next/link';
import { RxEyeOpen } from 'react-icons/rx';
import { MdFavorite } from 'react-icons/md';
import { RiCalendarCheckLine, RiCalendarScheduleLine } from 'react-icons/ri';
import { useGlobalStore } from '@/app/store/global';
type Props = Movie & {
showDayCounter?: boolean;
@@ -17,16 +17,14 @@ export const MovieCard: FC<Props> = ({
simpleToggle = false,
...movie
}) => {
const {
movies,
addMovie: addMovieToStore,
deleteMovie: deleteMovieFromStore,
updateMovie: updateMovieInStore,
} = useGlobalStore();
const movies = useGlobalStore(state => state.movies);
const addMovie = useGlobalStore(state => state.addMovie);
const deleteMovie = useGlobalStore(state => state.deleteMovie);
const updateMovie = useGlobalStore(state => state.updateMovie);
const { vote_average, popularity, poster_path, title, overview } = movie;
const { id } = movie;
const alreadyInStore = movies.find(m => m.id === id);
const alreadyInStore = movies.find(m => m.id == id);
const seen = alreadyInStore?.seen || movie.seen;
const favorite = alreadyInStore?.favorite || movie.favorite;
@@ -40,22 +38,22 @@ export const MovieCard: FC<Props> = ({
: 'from-red-400 to-pink-400';
const handleAdd = () => {
addMovieToStore(movie);
addMovie(movie);
};
const handleRemove = () => {
deleteMovieFromStore(id);
deleteMovie(id);
};
const handleSeen = () => {
updateMovieInStore(id, {
updateMovie(id, {
seen: !movie.seen,
favorite: false,
});
};
const handleFavorite = () => {
updateMovieInStore(id, {
updateMovie(id, {
favorite: !movie.favorite,
seen: movie.seen || !movie.favorite,
});

View File

@@ -3,8 +3,7 @@ import { formatter } from '@/helpers/formater';
import Link from 'next/link';
import { FC } from 'react';
import { FaCalendar, FaClock, FaStar, FaEye, FaHeart } from 'react-icons/fa';
import { motion, useAnimationControls, useMotionValue } from 'framer-motion';
import { useGlobalStore } from '@/app/store/globalStore';
import { useGlobalStore } from '@/app/store/global';
type Props = {
movie: Movie;
@@ -17,10 +16,7 @@ export const MovieRow: FC<Props> = ({
isUpcoming = false,
compact = false,
}) => {
const { movies, addMovie, updateMovie } = useGlobalStore();
const dragControls = useAnimationControls();
const x = useMotionValue(0);
const movies = useGlobalStore(state => state.movies);
const daysSinceRelease = Math.abs(
Math.floor(
@@ -30,128 +26,77 @@ export const MovieRow: FC<Props> = ({
);
// Check if movie is already in store.
const movieInStore = movies.find(m => m.id === movie.id);
const movieInStore = movies.find(m => m.id == movie.id);
const isWatched = movieInStore?.seen || false;
const isFavorite = movieInStore?.favorite || false;
const handleMarkAsWatched = () => {
if (movieInStore) {
updateMovie(movie.id, { seen: !isWatched });
} else {
addMovie({ ...movie, seen: true, favorite: false });
}
};
const handleAddToFavorites = () => {
if (movieInStore) {
updateMovie(movie.id, { favorite: !isFavorite, seen: true });
} else {
addMovie({ ...movie, seen: true, favorite: true });
}
};
const handleDragAction = () => {
const threshold = 70;
if (x.get() > threshold) {
handleAddToFavorites();
} else if (x.get() < -threshold) {
handleMarkAsWatched();
}
dragControls.start({
x: 0,
});
};
return (
<div className="relative overflow-hidden rounded-xl">
{/* Background actions */}
<div className="absolute inset-0 flex">
<div className="absolute right-0 h-full w-24 bg-green-500/20 flex items-center justify-center cursor-pointer">
<FaEye className="w-5 h-5 transition-colors text-green-500" />
</div>
<div className="absolute left-0 h-full w-24 bg-red-500/20 flex items-center justify-center cursor-pointer">
<FaHeart className="w-5 h-5 transition-colors text-red-500" />
</div>
</div>
<motion.div
drag="x"
style={{ x }}
animate={dragControls}
dragConstraints={{ left: -80, right: 80 }}
dragElastic={0.01}
dragMomentum={false}
whileDrag={{ cursor: 'grabbing' }}
onDragEnd={handleDragAction}
className="relative z-10"
<Link
href={`/film/${movie.id}`}
draggable={false}
className="flex items-center gap-4 p-3 rounded-lg bg-gray-800 hover:bg-gray-800 transition-colors group"
>
<Link
href={`/film/${movie.id}`}
draggable={false}
className="flex items-center gap-4 p-3 rounded-lg bg-gray-800 hover:bg-gray-800 transition-colors group"
>
<div className="relative w-12 h-16 rounded overflow-hidden flex-shrink-0">
<img
src={`https://image.tmdb.org/t/p/w154${movie.poster_path}`}
alt={movie.title}
className="object-cover inset-0"
sizes="48px"
/>
</div>
<div className="relative w-12 h-16 rounded overflow-hidden flex-shrink-0">
<img
src={`https://image.tmdb.org/t/p/w154${movie.poster_path}`}
alt={movie.title}
className="object-cover inset-0"
sizes="48px"
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-medium text-sm truncate group-hover:text-blue-400 transition-colors">
{movie.title}
</h3>
<div className="flex items-center gap-3 mt-1">
<div className="flex items-center gap-1 text-gray-400 text-xs">
{isUpcoming ? (
<FaCalendar className="w-3 h-3" />
) : (
<FaClock className="w-3 h-3" />
)}
<span>{formatter.formatDate(movie.release_date)}</span>
<div className="flex-1 min-w-0">
<h3 className="text-white font-medium text-sm truncate group-hover:text-blue-400 transition-colors">
{movie.title}
</h3>
<div className="flex items-center gap-3 mt-1">
<div className="flex items-center gap-1 text-gray-400 text-xs">
{isUpcoming ? (
<FaCalendar className="w-3 h-3" />
) : (
<FaClock className="w-3 h-3" />
)}
<span>{formatter.formatDate(movie.release_date)}</span>
</div>
{!!movie.vote_average && (
<div className="flex items-center gap-1 text-yellow-400 text-xs">
<FaStar className="w-3 h-3 fill-current" />
<span>{movie.vote_average.toFixed(1)}</span>
</div>
)}
{!!movie.vote_average && (
<div className="flex items-center gap-1 text-yellow-400 text-xs">
<FaStar className="w-3 h-3 fill-current" />
<span>{movie.vote_average.toFixed(1)}</span>
</div>
)}
{(isFavorite || movie.favorite) && (
<div
className="w-2 h-2 bg-red-500 rounded-full"
title="Ulubione"
/>
)}
{(isFavorite || movie.favorite) && (
<div
className="w-2 h-2 bg-red-500 rounded-full"
title="Ulubione"
/>
)}
{(isWatched || movie.seen) && (
<div
className="w-2 h-2 bg-green-500 rounded-full"
title="Obejrzane"
/>
)}
</div>
{(isWatched || movie.seen) && (
<div
className="w-2 h-2 bg-green-500 rounded-full"
title="Obejrzane"
/>
)}
</div>
</div>
{!compact && (
<div
className={`text-xs px-2 py-1 rounded-full font-medium ${
isUpcoming
? 'bg-blue-500/20 text-blue-400'
: 'bg-green-500/20 text-green-400'
}`}
>
{isUpcoming
? `za ${daysSinceRelease} dni`
: `od ${daysSinceRelease} dni`}
</div>
)}
</Link>
</motion.div>
{!compact && (
<div
className={`text-xs px-2 py-1 rounded-full font-medium ${
isUpcoming
? 'bg-blue-500/20 text-blue-400'
: 'bg-green-500/20 text-green-400'
}`}
>
{isUpcoming
? `za ${daysSinceRelease} dni`
: `od ${daysSinceRelease} dni`}
</div>
)}
</Link>
</div>
);
};

View File

@@ -1,9 +1,9 @@
"use client";
'use client';
import { BackButton } from "@/components/atoms/BackButton";
import { formatter } from "@/helpers/formater";
import { PersonDetailsRich } from "@/lib/tmdb/types";
import { FC } from "react";
import { BackButton } from '@/components/atoms/BackButton';
import { formatter } from '@/helpers/formater';
import { PersonDetailsRich } from '@/lib/tmdb/types';
import { FC } from 'react';
import {
FaCalendarAlt,
FaMapMarkerAlt,
@@ -15,7 +15,7 @@ import {
FaTwitter,
FaYoutube,
FaTiktok,
} from "react-icons/fa";
} from 'react-icons/fa';
type Props = {
personDetails: PersonDetailsRich;
@@ -37,13 +37,13 @@ export const ActorHero: FC<Props> = ({ personDetails }) => {
const getGenderText = (gender: number) => {
switch (gender) {
case 1:
return "Kobieta";
return 'Kobieta';
case 2:
return "Mężczyzna";
return 'Mężczyzna';
case 3:
return "Niebinarne";
return 'Niebinarne';
default:
return "Nie określono";
return 'Nie określono';
}
};
@@ -65,7 +65,7 @@ export const ActorHero: FC<Props> = ({ personDetails }) => {
src={
personDetails.profile_path
? `https://image.tmdb.org/t/p/w500${personDetails.profile_path}`
: "/api/placeholder/400/600"
: '/api/placeholder/400/600'
}
alt={personDetails.name}
className="w-80 h-auto rounded-2xl shadow-2xl shadow-purple-500/20 group-hover:shadow-purple-500/40 transition-all duration-500"
@@ -88,9 +88,9 @@ export const ActorHero: FC<Props> = ({ personDetails }) => {
{calculateAge(
personDetails.birthday,
personDetails.deathday
)}{" "}
)}{' '}
lat
{personDetails.deathday && " w chwili śmierci"})
{personDetails.deathday && ' w chwili śmierci'})
</span>
)}
@@ -114,8 +114,8 @@ export const ActorHero: FC<Props> = ({ personDetails }) => {
{personDetails.also_known_as.length > 0 && (
<div className="mb-4">
<p className="text-gray-400 text-sm">
Znany również jako:{" "}
{personDetails.also_known_as.slice(0, 3).join(", ")}
Znany również jako:{' '}
{personDetails.also_known_as.slice(0, 3).join(', ')}
</p>
</div>
)}
@@ -171,7 +171,7 @@ export const ActorHero: FC<Props> = ({ personDetails }) => {
</h3>
<div className="text-gray-300 leading-relaxed text-lg space-y-4">
{personDetails.biography
.split("\n\n")
.split('\n\n')
.map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
@@ -185,7 +185,7 @@ export const ActorHero: FC<Props> = ({ personDetails }) => {
<h3 className="text-lg font-semibold mb-3 text-purple-300">
Linki
</h3>
<div className="flex gap-4">
<div className="flex flex-wrap gap-4">
{Object.entries(personDetails.external_ids).map(
([key, value]) => {
if (!(key in externalIdsMap) || !value) {
@@ -222,32 +222,32 @@ export const ActorHero: FC<Props> = ({ personDetails }) => {
const externalIdsMap = {
facebook_id: {
label: "Facebook",
label: 'Facebook',
icon: <FaFacebook />,
url: (id: string) => `https://www.facebook.com/${id}`,
},
instagram_id: {
label: "Instagram",
label: 'Instagram',
icon: <FaInstagram />,
url: (id: string) => `https://www.instagram.com/${id}`,
},
twitter_id: {
label: "Twitter",
label: 'Twitter',
icon: <FaTwitter />,
url: (id: string) => `https://www.twitter.com/${id}`,
},
tiktok_id: {
label: "TikTok",
label: 'TikTok',
icon: <FaTiktok />,
url: (id: string) => `https://www.tiktok.com/${id}`,
},
youtube_id: {
label: "YouTube",
label: 'YouTube',
icon: <FaYoutube />,
url: (id: string) => `https://www.youtube.com/${id}`,
},
imdb_id: {
label: "IMDb",
label: 'IMDb',
icon: <FaImdb />,
url: (id: string) => `https://www.imdb.com/name/${id}`,
},

View File

@@ -1,10 +1,9 @@
"use client";
import { BackButton } from "@/components/atoms/BackButton";
import { Button } from "@/components/atoms/Button";
import { GenreLabel } from "@/components/atoms/GenreLabel";
import { MovieDetailsRich } from "@/lib/tmdb/types";
import { useGlobalStore } from "@/app/store/globalStore";
import { FC } from "react";
'use client';
import { BackButton } from '@/components/atoms/BackButton';
import { Button } from '@/components/atoms/Button';
import { GenreLabel } from '@/components/atoms/GenreLabel';
import { MovieDetailsRich } from '@/lib/tmdb/types';
import { FC } from 'react';
import {
FaHeart,
FaBookmark,
@@ -12,19 +11,23 @@ import {
FaCalendar,
FaGlobe,
FaEye,
} from "react-icons/fa";
import { convertToMovie } from "@/helpers/convertToMovie";
import { formatter } from "@/helpers/formater";
} from 'react-icons/fa';
import { convertToMovie } from '@/helpers/convertToMovie';
import { formatter } from '@/helpers/formater';
import { useGlobalStore } from '@/app/store/global';
type Props = {
movieDetails: MovieDetailsRich;
};
export const HeroMovie: FC<Props> = ({ movieDetails }) => {
const { movies, addMovie, deleteMovie, updateMovie } = useGlobalStore();
const movies = useGlobalStore(state => state.movies);
const addMovie = useGlobalStore(state => state.addMovie);
const deleteMovie = useGlobalStore(state => state.deleteMovie);
const updateMovie = useGlobalStore(state => state.updateMovie);
// Check if movie is in store and get its state.
const movieInStore = movies.find((m) => m.id === movieDetails.id);
const movieInStore = movies.find(m => m.id == movieDetails.id);
const isInStore = !!movieInStore;
const isFavorite = movieInStore?.favorite || false;
const isSeen = movieInStore?.seen || false;
@@ -115,8 +118,8 @@ export const HeroMovie: FC<Props> = ({ movieDetails }) => {
key={i}
className={`text-2xl ${
i < Math.round(movieDetails.vote_average / 2)
? "text-yellow-400"
: "text-gray-600"
? 'text-yellow-400'
: 'text-gray-600'
}`}
>
@@ -173,7 +176,7 @@ export const HeroMovie: FC<Props> = ({ movieDetails }) => {
Gatunki
</h3>
<div className="flex flex-wrap gap-2">
{movieDetails.genres.map((genre) => (
{movieDetails.genres.map(genre => (
<GenreLabel
key={genre.id}
genre={genre.name}
@@ -203,47 +206,47 @@ export const HeroMovie: FC<Props> = ({ movieDetails }) => {
gradient={
isInStore
? {
from: "from-purple-600 hover:from-purple-500",
to: "to-emerald-600 hover:to-emerald-500",
from: 'from-purple-600 hover:from-purple-500',
to: 'to-emerald-600 hover:to-emerald-500',
}
: {
from: "from-purple-600 hover:from-purple-500",
to: "to-pink-600 hover:to-pink-500",
from: 'from-purple-600 hover:from-purple-500',
to: 'to-pink-600 hover:to-pink-500',
}
}
className={`flex items-center gap-3`}
onClick={handleAddToList}
>
<FaBookmark />
{isInStore ? "Usuń z listy" : "Dodaj do listy"}
{isInStore ? 'Usuń z listy' : 'Dodaj do listy'}
</Button>
<Button
theme={isFavorite ? "rosePink" : "glass"}
theme={isFavorite ? 'rosePink' : 'glass'}
className={`flex items-center gap-3 ${
isFavorite
? "bg-gradient-to-r border-rose-400/30"
: ""
? 'bg-gradient-to-r border-rose-400/30'
: ''
}`}
onClick={handleToggleFavorite}
>
<FaHeart
className={isFavorite ? "text-rose-200" : ""}
className={isFavorite ? 'text-rose-200' : ''}
/>
{isFavorite
? "Usuń z ulubionych"
: "Dodaj do ulubionych"}
? 'Usuń z ulubionych'
: 'Dodaj do ulubionych'}
</Button>
<Button
theme={isSeen ? "emeraldTeal" : "glass"}
theme={isSeen ? 'emeraldTeal' : 'glass'}
className={`flex items-center gap-3 ${
isSeen ? "bg-gradient-to-r border-emerald-400/30" : ""
isSeen ? 'bg-gradient-to-r border-emerald-400/30' : ''
}`}
onClick={handleToggleSeen}
>
<FaEye className={isSeen ? "text-emerald-200" : ""} />
<FaEye className={isSeen ? 'text-emerald-200' : ''} />
{isSeen
? "Oznacz jako nieobejrzany"
: "Oznacz jako obejrzany"}
? 'Oznacz jako nieobejrzany'
: 'Oznacz jako obejrzany'}
</Button>
</div>
)}

View File

@@ -1,10 +1,10 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/atoms/Button";
import { MovieDetailsRich } from "@/lib/tmdb/types";
import { FC, useState } from "react";
import { FaDollarSign } from "react-icons/fa";
import { formatter } from "@/helpers/formater";
'use client';
import Link from 'next/link';
import { Button } from '@/components/atoms/Button';
import { MovieDetailsRich } from '@/lib/tmdb/types';
import { FC, useState } from 'react';
import { FaDollarSign } from 'react-icons/fa';
import { formatter } from '@/helpers/formater';
type Props = {
movieDetails: MovieDetailsRich;
@@ -13,16 +13,10 @@ type Props = {
export const MovieCast: FC<Props> = ({ movieDetails }) => {
const [limit, setLimit] = useState(8);
const director = movieDetails?.credits.crew.find(
(member) => member.job === "Director"
member => member.job === 'Director'
);
const mainCast = movieDetails?.credits.cast.slice(0, limit) || [];
const formatCurrency = (amount: number) =>
new Intl.NumberFormat("pl-PL", {
style: "currency",
currency: "USD",
}).format(amount);
return (
<section className="blocks">
<div className="container mx-auto">
@@ -32,7 +26,7 @@ export const MovieCast: FC<Props> = ({ movieDetails }) => {
<div className="lg:col-span-2">
<h2 className="text-2xl font-bold text-white mb-6">Obsada</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
{mainCast.map((actor) => (
{mainCast.map(actor => (
<Link
key={actor.id}
href={`/aktor/${actor.id}`}
@@ -41,12 +35,12 @@ export const MovieCast: FC<Props> = ({ movieDetails }) => {
<div className="relative overflow-hidden rounded-xl mb-3">
<img
style={{
aspectRatio: "185/278",
aspectRatio: '185/278',
}}
src={
actor.profile_path
? `https://image.tmdb.org/t/p/w185${actor.profile_path}`
: "/api/placeholder/185/278"
: '/api/placeholder/185/278'
}
alt={actor.name}
className="w-full object-cover group-hover:scale-110 transition-transform duration-500 bg-gradient-to-br from-purple-500/20 to-cyan-500/20"
@@ -147,7 +141,7 @@ export const MovieCast: FC<Props> = ({ movieDetails }) => {
<div className="space-y-2">
{movieDetails.production_companies
.slice(0, 3)
.map((company) => (
.map(company => (
<p key={company.id} className="text-gray-300">
{company.name}
</p>

View File

@@ -1,13 +1,14 @@
'use client';
import { FC, ReactNode, useState } from 'react';
import { MovieCard } from '@/components/atoms/MovieCard';
import { useGlobalStore } from '@/app/store/globalStore';
import { Dropdown } from '@/components/atoms/Dropdown';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import { Button } from '@/components/atoms/Button';
import { Label } from '@/components/atoms/Label';
import { FaList } from 'react-icons/fa';
import { MovieRow } from '@/components/atoms/MovieRow';
import { useGlobalStore } from '@/app/store/global';
import { SearchInput } from '@/components/atoms/SearchInput';
type Props = {
heading?: string;
@@ -17,6 +18,7 @@ type Props = {
overrideMovies?: Movie[];
overrideDisplayType?: 'grid' | 'list';
showSearch?: boolean;
showFilters?: boolean;
filterSeen?: 0 | 1;
filterFavorites?: 0 | 1;
@@ -25,7 +27,7 @@ type Props = {
fluid?: boolean;
showSorting?: boolean;
sort?: 'title' | 'releaseDate' | 'popularity';
sort?: 'title' | 'releaseDate' | 'popularity' | 'voteAverage';
sortDirection?: 'asc' | 'desc';
loadMore?: boolean;
@@ -37,6 +39,7 @@ export const MovieList: FC<Props> = ({
colors = 'white',
overrideMovies,
showFilters = true,
showSearch = false,
filterSeen: filterSeenInitial,
filterFavorites: filterFavoritesInitial,
filterUpcoming: filterUpcomingInitial,
@@ -48,23 +51,23 @@ export const MovieList: FC<Props> = ({
loadMore = false,
overrideDisplayType,
}) => {
const {
movies: storeMovies,
displayType: displayTypeInitial,
setDisplayType,
} = useGlobalStore();
const storeMovies = useGlobalStore(state => state.movies);
const displayTypeInitial = useGlobalStore(state => state.displayType);
const setDisplayType = useGlobalStore(state => state.setDisplayType);
const movies = overrideMovies || storeMovies;
const displayType = overrideDisplayType || displayTypeInitial;
const [filter, setFilter] = useState({
search: '',
seen: filterSeenInitial,
favorites: filterFavoritesInitial,
upcoming: filterUpcomingInitial,
released: filterReleasedInitial,
});
const [sort, setSort] = useState<'title' | 'releaseDate' | 'popularity'>(
sortType
);
const [sort, setSort] = useState<
'title' | 'releaseDate' | 'popularity' | 'voteAverage'
>(sortType);
const [loaded, setLoaded] = useState(8);
const [parent] = useAutoAnimate();
@@ -88,6 +91,9 @@ export const MovieList: FC<Props> = ({
result =
result && filter.released ? releaseDate < today : releaseDate > today;
}
if (filter.search) {
result = movie.title.toLowerCase().includes(filter.search.toLowerCase());
}
return result;
});
@@ -98,6 +104,7 @@ export const MovieList: FC<Props> = ({
new Date(b.release_date).getTime() - new Date(a.release_date).getTime()
);
if (sort === 'popularity') return b.popularity - a.popularity;
if (sort === 'voteAverage') return b.vote_average - a.vote_average;
return 0;
});
@@ -108,6 +115,7 @@ export const MovieList: FC<Props> = ({
const handleFilter = (key?: keyof typeof filter) => {
setFilter({
search: '',
seen: filterSeenInitial,
favorites: filterFavoritesInitial,
upcoming: filterUpcomingInitial,
@@ -168,6 +176,7 @@ export const MovieList: FC<Props> = ({
}
onClick={() =>
setFilter({
search: '',
seen: 0,
released: 1,
favorites: filterFavoritesInitial,
@@ -215,6 +224,7 @@ export const MovieList: FC<Props> = ({
{ label: 'Tytuł', value: 'title' },
{ label: 'Data premiery', value: 'releaseDate' },
{ label: 'Popularność', value: 'popularity' },
{ label: 'Ocena', value: 'voteAverage' },
]}
defaultValue={sort}
callback={value => setSort(value as 'title')}
@@ -223,6 +233,15 @@ export const MovieList: FC<Props> = ({
)}
</div>
)}
{showSearch && (
<div className="flex justify-end mt-6">
<SearchInput
className="w-full"
placeholder="Wyszukaj film..."
onChange={value => setFilter({ ...filter, search: value })}
/>
</div>
)}
{filteredMovies.length === 0 && (
<p className="text-text/60 text-sm">Brak filmów</p>
)}

View File

@@ -1,8 +1,8 @@
'use client';
import { FC, useMemo, useState } from 'react';
import { useGlobalStore } from '@/app/store/globalStore';
import { Button } from '@/components/atoms/Button';
import { FaDice } from 'react-icons/fa';
import { useGlobalStore } from '@/app/store/global';
import Link from 'next/link';
type StoreFilter = 'all' | 'not_seen' | 'released' | 'favorites' | 'to_watch';
@@ -20,7 +20,7 @@ export const RandomMovie: FC<Props> = ({
colors = 'purple',
className = '',
}) => {
const { movies } = useGlobalStore();
const movies = useGlobalStore(state => state.movies);
const [selectedMovie, setSelectedMovie] = useState<Movie | null>(null);
// Filter movies based on the selected store filter.

View File

@@ -1,8 +1,8 @@
'use client';
import { FC } from 'react';
import { useGlobalStore } from '@/app/store/globalStore';
import { FaCalendar, FaClock } from 'react-icons/fa';
import { MovieRow } from '@/components/atoms/MovieRow';
import { useGlobalStore } from '@/app/store/global';
type Props = {
overrideMovies?: Movie[];
@@ -17,7 +17,7 @@ export const TrackedMovies: FC<Props> = ({
labelCurrent = 'Aktualnie w kinach',
labelUpcoming = 'Nadchodzące premiery',
}) => {
const { movies: storeMovies } = useGlobalStore();
const storeMovies = useGlobalStore(state => state.movies);
const movies = overrideMovies || storeMovies;

View File

@@ -8,9 +8,9 @@ import {
FaMinus,
} from 'react-icons/fa';
import { RiCalendarCheckLine, RiCalendarScheduleLine } from 'react-icons/ri';
import { useGlobalStore } from '@/app/store/globalStore';
import Link from 'next/link';
import { Button } from '@/components/atoms/Button';
import { useGlobalStore } from '@/app/store/global';
type Props = {
movies: Movie[];
@@ -28,11 +28,9 @@ export const Hero: FC<Props> = ({
const [currentIndex, setCurrentIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const {
movies: storedMovies,
addMovie: addMovieToStore,
deleteMovie: deleteMovieInStore,
} = useGlobalStore();
const storedMovies = useGlobalStore(state => state.movies);
const addMovie = useGlobalStore(state => state.addMovie);
const deleteMovie = useGlobalStore(state => state.deleteMovie);
const currentMovie = movies[currentIndex];
@@ -92,11 +90,11 @@ export const Hero: FC<Props> = ({
}, [autoRotate, rotateInterval, nextSlide, movies.length]);
const handleAdd = async () => {
addMovieToStore(currentMovie);
addMovie(currentMovie);
};
const handleRemove = async () => {
deleteMovieInStore(id);
deleteMovie(id);
};
return (