Skip to content

Commit e0f7513

Browse files
Merge branch 'main' into main
2 parents ea218f9 + d1f9668 commit e0f7513

File tree

2 files changed

+158
-67
lines changed

2 files changed

+158
-67
lines changed

src/pages/Movies.jsx

Lines changed: 115 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,11 @@
1-
/**
2-
* MOVIES (GHIBLI) DASHBOARD TODOs
3-
* -------------------------------
4-
* Easy:
5-
* - [x] Add IMDb Ratings using OMDb API (with caching)
6-
* - [ ] Add select dropdown for director filtering instead of text filter
7-
* - [ ] Add film poster (map titles to known images or placeholder search)
8-
* - [ ] Show running time, score, producer fields
9-
* - [ ] Expand/collapse description
10-
* Medium:
11-
* - [ ] Client-side pagination / virtualization for performance (future if many APIs)
12-
* - [ ] Favorites / watchlist (localStorage)
13-
* - [ ] Sort (Year asc/desc, Title A-Z, RT Score)
14-
* - [ ] Detail modal with full info & external links
15-
* Advanced:
16-
* - [ ] Pre-fetch details or combine with other Studio Ghibli endpoints (people, locations)
17-
* - [ ] Add fuzzy search (title, director, description)
18-
* - [ ] Offline cache using indexedDB (e.g., idb library)
19-
* - [ ] Extract data layer + hook (useGhibliFilms)
20-
*/
21-
221
import { useEffect, useState } from 'react';
232
import Loading from '../components/Loading.jsx';
243
import ErrorMessage from '../components/ErrorMessage.jsx';
254
import Card from '../components/Card.jsx';
265
import HeroSection from '../components/HeroSection';
276
import Cinema from '../Images/Movie.jpg';
287
import Modal from '../components/Modal.jsx';
8+
import { getCachedMovies, saveMoviesToCache } from '../utilities/db';
299

3010
export default function Movies() {
3111
const [films, setFilms] = useState([]);
@@ -34,12 +14,15 @@ export default function Movies() {
3414
const [filter, setFilter] = useState('');
3515
const [isModalOpen, setIsModalOpen] = useState(false);
3616
const [selectedFilm, setSelectedFilm] = useState(null);
17+
const [isOffline, setIsOffline] = useState(!navigator.onLine);
3718

3819
const OMDB_API_KEY = import.meta.env.VITE_OMDB_API_KEY;
3920
const isLocal = window.location.hostname === 'localhost';
4021

4122
// 🧠 Fetch and cache IMDb rating (refresh automatically in local)
4223
async function fetchIMDbRating(title) {
24+
if (!OMDB_API_KEY) return { imdbRating: 'N/A' };
25+
4326
const cacheKey = `imdb_${title}`;
4427
const cached = localStorage.getItem(cacheKey);
4528

@@ -48,11 +31,10 @@ export default function Movies() {
4831
try {
4932
return JSON.parse(cached);
5033
} catch {
51-
// continue to fetch
34+
// fall through to fetch
5235
}
5336
}
5437

55-
// Otherwise, fetch fresh data
5638
try {
5739
const res = await fetch(
5840
`https://www.omdbapi.com/?t=${encodeURIComponent(title)}&apikey=${OMDB_API_KEY}`
@@ -71,40 +53,100 @@ export default function Movies() {
7153
return { imdbRating: 'N/A' };
7254
}
7355

74-
// 🎬 Fetch Ghibli films and attach IMDb ratings
75-
async function fetchFilms() {
76-
try {
77-
setLoading(true);
78-
setError(null);
79-
80-
const res = await fetch('https://ghibliapi.vercel.app/films');
81-
if (!res.ok) throw new Error('Failed to fetch films');
82-
const filmsData = await res.json();
83-
84-
const filmsWithRatings = await Promise.all(
85-
filmsData.map(async (film) => {
86-
const imdb = await fetchIMDbRating(film.title);
87-
return { ...film, ...imdb };
88-
})
89-
);
56+
// Listen for online/offline and update state
57+
useEffect(() => {
58+
const handleOffline = () => setIsOffline(true);
59+
const handleOnline = () => setIsOffline(false);
9060

91-
setFilms(filmsWithRatings);
92-
} catch (e) {
93-
setError(e);
94-
} finally {
95-
setLoading(false);
96-
}
97-
}
61+
window.addEventListener('offline', handleOffline);
62+
window.addEventListener('online', handleOnline);
9863

99-
useEffect(() => {
100-
fetchFilms();
64+
return () => {
65+
window.removeEventListener('offline', handleOffline);
66+
window.removeEventListener('online', handleOnline);
67+
};
10168
}, []);
10269

70+
// Load films (with offline cache fallback and IMDb enrichment)
71+
useEffect(() => {
72+
let cancelled = false;
73+
74+
async function loadMovies() {
75+
try {
76+
setLoading(true);
77+
setError(null);
78+
79+
if (isOffline) {
80+
// --- OFFLINE LOGIC ---
81+
const cachedFilms = await getCachedMovies();
82+
if (cachedFilms && cachedFilms.length > 0) {
83+
if (!cancelled) setFilms(cachedFilms);
84+
} else {
85+
throw new Error('You are offline and no cached movies are available.');
86+
}
87+
} else {
88+
// --- ONLINE LOGIC ---
89+
const res = await fetch('https://ghibliapi.vercel.app/films');
90+
if (!res.ok) throw new Error('Failed to fetch from API');
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+
}
110+
}
111+
} catch (e) {
112+
console.error('Error during data loading:', e);
113+
114+
// Attempt cache fallback if online fetch failed
115+
if (!isOffline) {
116+
try {
117+
const cachedFilms = await getCachedMovies();
118+
if (cachedFilms && cachedFilms.length > 0) {
119+
if (!cancelled) {
120+
setFilms(cachedFilms);
121+
setError(null);
122+
}
123+
} else {
124+
if (!cancelled) setError(e);
125+
}
126+
} catch (cacheError) {
127+
console.error('Cache fallback failed:', cacheError);
128+
if (!cancelled) setError(e);
129+
}
130+
} else {
131+
if (!cancelled) setError(e);
132+
}
133+
} finally {
134+
if (!cancelled) setLoading(false);
135+
}
136+
}
137+
138+
loadMovies();
139+
140+
return () => {
141+
cancelled = true;
142+
};
143+
}, [isOffline]);
144+
103145
// 🧩 Filters
104146
const filtered = films.filter(
105147
(f) =>
106-
f.director.toLowerCase().includes(filter.toLowerCase()) ||
107-
f.release_date.includes(filter)
148+
f.director?.toLowerCase().includes(filter.toLowerCase()) ||
149+
f.release_date?.includes(filter)
108150
);
109151

110152
// 🎞️ Modal Handlers
@@ -129,6 +171,22 @@ export default function Movies() {
129171
}
130172
subtitle="Dive deep into storytelling, performances, and the art of filmmaking."
131173
/>
174+
175+
{isOffline && (
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+
>
186+
You are in offline mode.
187+
</div>
188+
)}
189+
132190
<h2>Studio Ghibli Films</h2>
133191

134192
<input
@@ -144,40 +202,30 @@ export default function Movies() {
144202
{filtered.map((f) => (
145203
<div
146204
key={f.id}
147-
type="button"
205+
role="button"
148206
onClick={() => openModal(f)}
149207
aria-label={`Open details for ${f.title}`}
150208
style={{ display: 'contents', cursor: 'pointer' }}
151209
>
152-
<Card
153-
title={`${f.title} (${f.release_date})`}
154-
japaneseTitle={f.original_title}
155-
image={f.image}
156-
>
210+
<Card title={`${f.title} (${f.release_date})`} japaneseTitle={f.original_title} image={f.image}>
157211
<p>
158212
<strong>Director:</strong> {f.director}
159213
</p>
214+
160215
<p>
161-
<span
162-
style={{
163-
fontStyle: 'italic',
164-
fontWeight: 500,
165-
color: 'white',
166-
}}
167-
>
216+
<span style={{ fontStyle: 'italic', fontWeight: 500, color: 'white' }}>
168217
IMDb Rating:
169218
</span>{' '}
170219
{f.imdbRating || 'N/A'}
171220
</p>
172-
<p>{f.description.slice(0, 120)}...</p>
221+
222+
<p>{f.description?.slice(0, 120)}...</p>
173223
</Card>
174224
</div>
175225
))}
176226
</div>
177227

178-
{isModalOpen && selectedFilm && (
179-
<Modal open={isModalOpen} onClose={closeModal} film={selectedFilm} />
180-
)}
228+
{isModalOpen && selectedFilm && <Modal open={isModalOpen} onClose={closeModal} film={selectedFilm} />}
181229
</div>
182230
);
183231
}

src/utilities/db.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { openDB } from 'idb';
2+
3+
const DB_NAME = 'movie-db';
4+
const STORE_NAME = 'movies';
5+
const DB_VERSION = 1;
6+
7+
async function initDB() {
8+
const db = await openDB(DB_NAME, DB_VERSION, {
9+
upgrade(db) {
10+
if (!db.objectStoreNames.contains(STORE_NAME)) {
11+
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
12+
}
13+
},
14+
});
15+
return db;
16+
}
17+
18+
/**
19+
* Gets all movies from the IndexedDB cache.
20+
* @returns {Promise<Array>} A promise that resolves to an array of movie objects.
21+
*/
22+
export async function getCachedMovies() {
23+
const db = await initDB();
24+
return db.getAll(STORE_NAME);
25+
}
26+
27+
/**
28+
* Saves an array of movies to the IndexedDB cache.
29+
* It clears the old data and adds the new data.
30+
* @param {Array} movies - An array of movie objects to cache.
31+
*/
32+
export async function saveMoviesToCache(movies) {
33+
const db = await initDB();
34+
const tx = db.transaction(STORE_NAME, 'readwrite');
35+
36+
await tx.objectStore(STORE_NAME).clear();
37+
38+
await Promise.all(movies.map(movie => {
39+
return tx.objectStore(STORE_NAME).add(movie);
40+
}));
41+
42+
await tx.done;
43+
}

0 commit comments

Comments
 (0)