1- /**
2- * MOVIES (GHIBLI) DASHBOARD TODOs
3- * -------------------------------
4- * Easy:
5- * - [ ] Add select dropdown for director filtering instead of text filter
6- * - [ ] Add film poster (map titles to known images or placeholder search)
7- * - [ ] Show running time, score, producer fields
8- * - [ ] Expand/collapse description
9- * Medium:
10- * - [ ] Client-side pagination / virtualization for performance (future if many APIs)
11- * - [ ] Favorites / watchlist (localStorage)
12- * - [ ] Sort (Year asc/desc, Title A-Z, RT Score)
13- * - [ ] Detail modal with full info & external links
14- * Advanced:
15- * - [ ] Pre-fetch details or combine with other Studio Ghibli endpoints (people, locations)
16- * - [ ] Add fuzzy search (title, director, description)
17- * - [X] Offline cache using indexedDB (e.g., idb library)
18- * - [ ] Extract data layer + hook (useGhibliFilms)
19- */
201import { useEffect , useState } from 'react' ;
212import Loading from '../components/Loading.jsx' ;
223import ErrorMessage from '../components/ErrorMessage.jsx' ;
@@ -35,15 +16,47 @@ export default function Movies() {
3516 const [ selectedFilm , setSelectedFilm ] = useState ( null ) ;
3617 const [ isOffline , setIsOffline ] = useState ( ! navigator . onLine ) ;
3718
19+ const OMDB_API_KEY = import . meta. env . VITE_OMDB_API_KEY ;
20+ const isLocal = window . location . hostname === 'localhost' ;
21+
22+ // 🧠 Fetch and cache IMDb rating (refresh automatically in local)
23+ async function fetchIMDbRating ( title ) {
24+ if ( ! OMDB_API_KEY ) return { imdbRating : 'N/A' } ;
25+
26+ const cacheKey = `imdb_${ title } ` ;
27+ const cached = localStorage . getItem ( cacheKey ) ;
28+
29+ // If not local and cached data exists, use it
30+ if ( ! isLocal && cached ) {
31+ try {
32+ return JSON . parse ( cached ) ;
33+ } catch {
34+ // fall through to fetch
35+ }
36+ }
37+
38+ try {
39+ const res = await fetch (
40+ `https://www.omdbapi.com/?t=${ encodeURIComponent ( title ) } &apikey=${ OMDB_API_KEY } `
41+ ) ;
42+ const data = await res . json ( ) ;
43+
44+ if ( data && data . imdbRating ) {
45+ const ratingObj = { imdbRating : data . imdbRating } ;
46+ if ( ! isLocal ) localStorage . setItem ( cacheKey , JSON . stringify ( ratingObj ) ) ;
47+ return ratingObj ;
48+ }
49+ } catch ( err ) {
50+ console . error ( 'Failed to fetch IMDb rating for' , title , err ) ;
51+ }
52+
53+ return { imdbRating : 'N/A' } ;
54+ }
55+
56+ // Listen for online/offline and update state
3857 useEffect ( ( ) => {
39- const handleOffline = ( ) => {
40- console . log ( 'App is offline' ) ;
41- setIsOffline ( true ) ;
42- } ;
43- const handleOnline = ( ) => {
44- console . log ( 'App is online' ) ;
45- setIsOffline ( false ) ;
46- } ;
58+ const handleOffline = ( ) => setIsOffline ( true ) ;
59+ const handleOnline = ( ) => setIsOffline ( false ) ;
4760
4861 window . addEventListener ( 'offline' , handleOffline ) ;
4962 window . addEventListener ( 'online' , handleOnline ) ;
@@ -54,7 +67,10 @@ export default function Movies() {
5467 } ;
5568 } , [ ] ) ;
5669
70+ // Load films (with offline cache fallback and IMDb enrichment)
5771 useEffect ( ( ) => {
72+ let cancelled = false ;
73+
5874 async function loadMovies ( ) {
5975 try {
6076 setLoading ( true ) ;
@@ -63,51 +79,77 @@ export default function Movies() {
6379 if ( isOffline ) {
6480 // --- OFFLINE LOGIC ---
6581 const cachedFilms = await getCachedMovies ( ) ;
66- if ( cachedFilms . length > 0 ) {
67- setFilms ( cachedFilms ) ;
82+ if ( cachedFilms && cachedFilms . length > 0 ) {
83+ if ( ! cancelled ) setFilms ( cachedFilms ) ;
6884 } else {
69- setError ( new Error ( " You are offline and no cached movies are available." ) ) ;
85+ throw new Error ( ' You are offline and no cached movies are available.' ) ;
7086 }
7187 } else {
7288 // --- ONLINE LOGIC ---
7389 const res = await fetch ( 'https://ghibliapi.vercel.app/films' ) ;
7490 if ( ! res . ok ) throw new Error ( 'Failed to fetch from API' ) ;
75-
76- const json = await res . json ( ) ;
77- setFilms ( json ) ;
78-
79- await saveMoviesToCache ( json ) ;
91+
92+ const filmsData = await res . json ( ) ;
93+
94+ // Enrich with IMDb ratings in parallel (but limit concurrency if needed)
95+ const filmsWithRatings = await Promise . all (
96+ filmsData . map ( async ( film ) => {
97+ const imdb = await fetchIMDbRating ( film . title ) ;
98+ return { ...film , ...imdb } ;
99+ } )
100+ ) ;
101+
102+ if ( ! cancelled ) setFilms ( filmsWithRatings ) ;
103+
104+ // Save to indexedDB / cache for offline use
105+ try {
106+ await saveMoviesToCache ( filmsWithRatings ) ;
107+ } catch ( cacheErr ) {
108+ console . warn ( 'Failed to save movies to cache:' , cacheErr ) ;
109+ }
80110 }
81111 } catch ( e ) {
82112 console . error ( 'Error during data loading:' , e ) ;
83-
113+
114+ // Attempt cache fallback if online fetch failed
84115 if ( ! isOffline ) {
85- console . log ( 'API failed, attempting to load from cache...' ) ;
86116 try {
87117 const cachedFilms = await getCachedMovies ( ) ;
88- if ( cachedFilms . length > 0 ) {
89- setFilms ( cachedFilms ) ;
90- setError ( null ) ;
118+ if ( cachedFilms && cachedFilms . length > 0 ) {
119+ if ( ! cancelled ) {
120+ setFilms ( cachedFilms ) ;
121+ setError ( null ) ;
122+ }
91123 } else {
92- setError ( new Error ( "API failed and no cached data is available." ) ) ;
124+ if ( ! cancelled ) setError ( e ) ;
93125 }
94126 } catch ( cacheError ) {
95127 console . error ( 'Cache fallback failed:' , cacheError ) ;
96- setError ( e ) ;
128+ if ( ! cancelled ) setError ( e ) ;
97129 }
98130 } else {
99- setError ( e ) ;
131+ if ( ! cancelled ) setError ( e ) ;
100132 }
101133 } finally {
102- setLoading ( false ) ;
134+ if ( ! cancelled ) setLoading ( false ) ;
103135 }
104136 }
105137
106138 loadMovies ( ) ;
107- } , [ isOffline ] ) ;
108139
109- const filtered = films . filter ( f => f . director . toLowerCase ( ) . includes ( filter . toLowerCase ( ) ) || f . release_date . includes ( filter ) ) ;
140+ return ( ) => {
141+ cancelled = true ;
142+ } ;
143+ } , [ isOffline ] ) ;
144+
145+ // 🧩 Filters
146+ const filtered = films . filter (
147+ ( f ) =>
148+ f . director ?. toLowerCase ( ) . includes ( filter . toLowerCase ( ) ) ||
149+ f . release_date ?. includes ( filter )
150+ ) ;
110151
152+ // 🎞️ Modal Handlers
111153 const openModal = ( film ) => {
112154 setSelectedFilm ( film ) ;
113155 setIsModalOpen ( true ) ;
@@ -121,55 +163,69 @@ export default function Movies() {
121163 return (
122164 < div >
123165 < HeroSection
124- image = { Cinema }
125- title = {
126- < >
127- Lights, Camera, < span style = { { color : 'darkred' } } > Binge!</ span >
128- </ >
129- }
130- subtitle = "Dive deep into storytelling, performances, and the art of filmmaking."
131- />
166+ image = { Cinema }
167+ title = {
168+ < >
169+ Lights, Camera, < span style = { { color : 'darkred' } } > Binge!</ span >
170+ </ >
171+ }
172+ subtitle = "Dive deep into storytelling, performances, and the art of filmmaking."
173+ />
174+
132175 { isOffline && (
133- < div style = { {
134- padding : '10px' ,
135- backgroundColor : '#333' ,
136- color : 'white' ,
137- textAlign : 'center' ,
138- fontWeight : 'bold' ,
139- margin : '10px 0'
140- } } >
176+ < div
177+ style = { {
178+ padding : '10px' ,
179+ backgroundColor : '#333' ,
180+ color : 'white' ,
181+ textAlign : 'center' ,
182+ fontWeight : 'bold' ,
183+ margin : '10px 0' ,
184+ } }
185+ >
141186 You are in offline mode.
142187 </ div >
143188 ) }
144189
145190 < h2 > Studio Ghibli Films</ h2 >
146- < input value = { filter } onChange = { e => setFilter ( e . target . value ) } placeholder = "Filter by director or year" />
191+
192+ < input
193+ value = { filter }
194+ onChange = { ( e ) => setFilter ( e . target . value ) }
195+ placeholder = "Filter by director or year"
196+ />
197+
147198 { loading && < Loading /> }
148199 < ErrorMessage error = { error } />
200+
149201 < div className = "grid" >
150- { filtered . map ( f => (
151- < div
202+ { filtered . map ( ( f ) => (
203+ < div
152204 key = { f . id }
153- type = "button"
205+ role = "button"
154206 onClick = { ( ) => openModal ( f ) }
155207 aria-label = { `Open details for ${ f . title } ` }
156- style = { {
157- display : 'contents' ,
158- cursor : 'pointer' ,
159- } }
208+ style = { { display : 'contents' , cursor : 'pointer' } }
160209 >
161210 < Card title = { `${ f . title } (${ f . release_date } )` } japaneseTitle = { f . original_title } image = { f . image } >
162- < p > < strong > Director:</ strong > { f . director } </ p >
163- < p > { f . description . slice ( 0 , 120 ) } ...</ p >
164- { /* TODO: Add poster images (need mapping) */ }
211+ < p >
212+ < strong > Director:</ strong > { f . director }
213+ </ p >
214+
215+ < p >
216+ < span style = { { fontStyle : 'italic' , fontWeight : 500 , color : 'white' } } >
217+ IMDb Rating:
218+ </ span > { ' ' }
219+ ⭐ { f . imdbRating || 'N/A' }
220+ </ p >
221+
222+ < p > { f . description ?. slice ( 0 , 120 ) } ...</ p >
165223 </ Card >
166224 </ div >
167-
168225 ) ) }
169226 </ div >
170- { isModalOpen && selectedFilm && (
171- < Modal open = { isModalOpen } onClose = { closeModal } film = { selectedFilm } />
172- ) }
227+
228+ { isModalOpen && selectedFilm && < Modal open = { isModalOpen } onClose = { closeModal } film = { selectedFilm } /> }
173229 </ div >
174230 ) ;
175231}
0 commit comments