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": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"swr": "^2.3.6" "swr": "^2.3.6",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -1593,7 +1594,7 @@
"version": "19.1.9", "version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz",
"integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -1703,7 +1704,7 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
@@ -2770,6 +2771,35 @@
"engines": { "engines": {
"node": ">=18" "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": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"swr": "^2.3.6" "swr": "^2.3.6",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@@ -1,10 +1,10 @@
import { MovieCard } from "@/components/atoms/MovieCard"; import { MovieCard } from '@/components/atoms/MovieCard';
import { ActorHero } from "@/components/molecules/ActorHero"; import { ActorHero } from '@/components/molecules/ActorHero';
import { Carousel } from "@/components/molecules/Carousel"; import { Carousel } from '@/components/molecules/Carousel';
import { Gallery } from "@/components/molecules/Gallery"; import { Gallery } from '@/components/molecules/Gallery';
import { convertToMovie } from "@/helpers/convertToMovie"; import { convertToMovie } from '@/helpers/convertToMovie';
import { TMDB } from "@/lib/tmdb"; import { TMDB } from '@/lib/tmdb';
import { FaStar } from "react-icons/fa"; import { FaStar } from 'react-icons/fa';
export default async function Page({ export default async function Page({
params, params,
@@ -32,7 +32,7 @@ export default async function Page({
new Date(b.release_date).getTime() - new Date(b.release_date).getTime() -
new Date(a.release_date).getTime() new Date(a.release_date).getTime()
) )
.map((movie) => { .map(movie => {
const convertedMovie = convertToMovie(movie); const convertedMovie = convertToMovie(movie);
if (!convertedMovie) return null; if (!convertedMovie) return null;
return <MovieCard key={movie.id} {...convertedMovie} />; 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 { Navbar } from '@/components/organisms/Navbar';
import { AuroraBackground } from '@/components/effects'; import { AuroraBackground } from '@/components/effects';
import { GlobalStoreProvider } from './store/globalStore';
import { DB_getMovies } from '@/lib/db/pb'; import { DB_getMovies } from '@/lib/db/pb';
import { GlobalProvider } from './store/global';
export default async function RootLayout({ export default async function RootLayout({
children, children,
@@ -23,11 +23,11 @@ export default async function RootLayout({
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
</head> </head>
<body className={`antialiased`}> <body className={`antialiased`}>
<GlobalStoreProvider initialMovies={movies}> <GlobalProvider initialMovies={movies}>
<AuroraBackground /> <AuroraBackground />
<Navbar /> <Navbar />
<main className="relative [&>*:last-child]:pb-16">{children}</main> <main className="relative [&>*:last-child]:pb-16">{children}</main>
</GlobalStoreProvider> </GlobalProvider>
</body> </body>
</html> </html>
); );

View File

@@ -7,7 +7,7 @@ export default async function Home() {
return ( return (
<> <>
<TrackedMovies /> <TrackedMovies />
<MovieList heading="Moja lista" /> <MovieList heading="Moja lista" showSearch />
<RandomMovie heading="Ciężko wybrać?" /> <RandomMovie heading="Ciężko wybrać?" />
<GenreList heading="Odkrywaj nowe filmy według gatunku" /> <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'; 'use client';
import { FC } from 'react'; import { FC } from 'react';
import { useGlobalStore } from '@/app/store/globalStore';
import { FaFire, FaPlusCircle, FaTrash } from 'react-icons/fa'; import { FaFire, FaPlusCircle, FaTrash } from 'react-icons/fa';
import Link from 'next/link'; import Link from 'next/link';
import { RxEyeOpen } from 'react-icons/rx'; import { RxEyeOpen } from 'react-icons/rx';
import { MdFavorite } from 'react-icons/md'; import { MdFavorite } from 'react-icons/md';
import { RiCalendarCheckLine, RiCalendarScheduleLine } from 'react-icons/ri'; import { RiCalendarCheckLine, RiCalendarScheduleLine } from 'react-icons/ri';
import { useGlobalStore } from '@/app/store/global';
type Props = Movie & { type Props = Movie & {
showDayCounter?: boolean; showDayCounter?: boolean;
@@ -17,16 +17,14 @@ export const MovieCard: FC<Props> = ({
simpleToggle = false, simpleToggle = false,
...movie ...movie
}) => { }) => {
const { const movies = useGlobalStore(state => state.movies);
movies, const addMovie = useGlobalStore(state => state.addMovie);
addMovie: addMovieToStore, const deleteMovie = useGlobalStore(state => state.deleteMovie);
deleteMovie: deleteMovieFromStore, const updateMovie = useGlobalStore(state => state.updateMovie);
updateMovie: updateMovieInStore,
} = useGlobalStore();
const { vote_average, popularity, poster_path, title, overview } = movie; const { vote_average, popularity, poster_path, title, overview } = movie;
const { id } = 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 seen = alreadyInStore?.seen || movie.seen;
const favorite = alreadyInStore?.favorite || movie.favorite; const favorite = alreadyInStore?.favorite || movie.favorite;
@@ -40,22 +38,22 @@ export const MovieCard: FC<Props> = ({
: 'from-red-400 to-pink-400'; : 'from-red-400 to-pink-400';
const handleAdd = () => { const handleAdd = () => {
addMovieToStore(movie); addMovie(movie);
}; };
const handleRemove = () => { const handleRemove = () => {
deleteMovieFromStore(id); deleteMovie(id);
}; };
const handleSeen = () => { const handleSeen = () => {
updateMovieInStore(id, { updateMovie(id, {
seen: !movie.seen, seen: !movie.seen,
favorite: false, favorite: false,
}); });
}; };
const handleFavorite = () => { const handleFavorite = () => {
updateMovieInStore(id, { updateMovie(id, {
favorite: !movie.favorite, favorite: !movie.favorite,
seen: movie.seen || !movie.favorite, seen: movie.seen || !movie.favorite,
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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