Skip to content

Commit a4a55f6

Browse files
Movies.jsx : Add movie details
1 parent dba1132 commit a4a55f6

3 files changed

Lines changed: 241 additions & 7 deletions

File tree

src/components/Card.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ export default function Card({ title,japaneseTitle,image, children, footer }) {
1414
{footer && <div className="card-footer">{footer}</div>}
1515
</div>
1616
);
17-
}
17+
}

src/components/Modal.jsx

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import React, { useEffect, useState } from 'react'
2+
import { createPortal } from 'react-dom'
3+
4+
export default function Modal({ open = false, onClose = () => {}, film = null, children = null }) {
5+
const [mounted, setMounted] = useState(false)
6+
const [peopleNames, setPeopleNames] = useState([])
7+
8+
const [isDark, setIsDark] = useState(() =>
9+
typeof window !== 'undefined' && window.matchMedia
10+
? window.matchMedia('(prefers-color-scheme: dark)').matches
11+
: false
12+
)
13+
14+
useEffect(() => {
15+
if (typeof window === 'undefined' || !window.matchMedia) return
16+
const mq = window.matchMedia('(prefers-color-scheme: dark)')
17+
const handler = (e) => setIsDark(e.matches)
18+
mq.addEventListener ? mq.addEventListener('change', handler) : mq.addListener(handler)
19+
return () => {
20+
mq.removeEventListener ? mq.removeEventListener('change', handler) : mq.removeListener(handler)
21+
}
22+
}, [])
23+
24+
useEffect(() => {
25+
if (!open) return
26+
setMounted(false)
27+
const raf = requestAnimationFrame(() => setMounted(true))
28+
const onKey = (e) => e.key === 'Escape' && onClose()
29+
window.addEventListener('keydown', onKey)
30+
return () => {
31+
cancelAnimationFrame(raf)
32+
window.removeEventListener('keydown', onKey)
33+
setMounted(false)
34+
setPeopleNames([])
35+
}
36+
}, [open, onClose])
37+
38+
useEffect(() => {
39+
let active = true
40+
async function fetchPeople(urls) {
41+
if (!urls?.length) return setPeopleNames([])
42+
try {
43+
const results = await Promise.all(
44+
urls.map((u) => fetch(u).then((r) => (r.ok ? r.json() : null)).catch(() => null))
45+
)
46+
if (!active) return
47+
const names = results.map((r) => r?.name || '').filter(Boolean)
48+
setPeopleNames(names)
49+
} catch {
50+
if (active) setPeopleNames([])
51+
}
52+
}
53+
fetchPeople(film?.people)
54+
return () => {
55+
active = false
56+
}
57+
}, [film])
58+
59+
if (!open) return null
60+
61+
const data = film || {}
62+
63+
const overlayStyle = {
64+
position: 'fixed',
65+
inset: 0,
66+
width: '100%',
67+
height: '100%',
68+
zIndex: 2147483640,
69+
display: 'block',
70+
background: 'rgba(15, 23, 42, 0.85)' ,
71+
}
72+
73+
const centeredWrapper = {
74+
position: 'fixed',
75+
top: '50%',
76+
left: '50%',
77+
transform: mounted ? 'translate(-50%, -50%) scale(1)' : 'translate(-50%, -48%) scale(0.985)',
78+
transition: 'transform 200ms cubic-bezier(.2,.9,.3,1), opacity 160ms ease',
79+
opacity: mounted ? 1 : 0,
80+
zIndex: 2147483647,
81+
width: 'min(720px, calc(100% - 40px))',
82+
maxHeight: '90vh',
83+
overflowY: 'auto',
84+
boxSizing: 'border-box',
85+
display: 'flex',
86+
alignItems: 'center',
87+
justifyContent: 'center',
88+
89+
}
90+
91+
92+
const dialogStyle = {
93+
background: isDark ? '#0f172a' : '#ffffff',
94+
color: isDark ? '#f8fafc' : '#0f172a',
95+
padding: '22px',
96+
borderRadius: '18px',
97+
width: '100%',
98+
maxWidth: '720px',
99+
lineHeight: 1.5,
100+
fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial',
101+
}
102+
103+
const imgStyle = {
104+
width: '100%',
105+
height: '280px',
106+
objectFit: 'cover',
107+
borderRadius: '10px',
108+
marginBottom: '14px',
109+
filter: isDark ? 'brightness(0.9)' : 'none',
110+
backgroundColor: isDark ? '#1e293b' : '#f3f4f6',
111+
}
112+
113+
const titleStyle = {
114+
margin: 0,
115+
fontSize: '1.25rem',
116+
marginBottom: '6px',
117+
fontWeight: 600,
118+
color: isDark ? '#f1f5f9' : '#111827',
119+
}
120+
const subtitleStyle = {
121+
margin: 0,
122+
fontSize: '0.95rem',
123+
fontStyle: 'italic',
124+
marginBottom: '10px',
125+
color: isDark ? '#94a3b8' : '#475569',
126+
}
127+
const descStyle = {
128+
marginTop: '12px',
129+
color: isDark ? '#fffff' : '#00000',
130+
whiteSpace: 'pre-wrap',
131+
}
132+
133+
const closeBtnStyle = {
134+
fontSize: '24px',
135+
lineHeight: 1,
136+
border: 'none',
137+
background: 'transparent',
138+
color: isDark ? '#f8fafc' : '#111827',
139+
borderRadius: '6px',
140+
padding: '4px 8px',
141+
cursor: 'pointer',
142+
transition: 'color 0.2s ease',
143+
}
144+
145+
146+
const scoreStyle = {
147+
fontWeight: 700,
148+
color: isDark ? '#facc15' : '#ca8a04',
149+
fontSize: '1.1rem',
150+
}
151+
152+
const modalNode = (
153+
<div style={overlayStyle} onClick={onClose}>
154+
<div
155+
style={centeredWrapper}
156+
onClick={(e) => e.stopPropagation()}
157+
role="dialog"
158+
aria-modal="true"
159+
aria-labelledby="modal-title"
160+
>
161+
<div style={dialogStyle}>
162+
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' }}>
163+
<button onClick={onClose} aria-label="Close modal" style={closeBtnStyle}>
164+
&times;
165+
</button>
166+
</div>
167+
168+
{data.movie_banner && (
169+
<img src={data.movie_banner} alt={data.title || 'poster'} style={imgStyle} />
170+
)}
171+
172+
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px' }}>
173+
<div style={{ flex: 1 }}>
174+
{data.title && (
175+
<h3 id="modal-title" style={titleStyle}>
176+
{data.title} {data.release_date ? `(${data.release_date})` : ''}
177+
</h3>
178+
)}
179+
{data.original_title && (
180+
<p style={subtitleStyle}>
181+
{data.original_title}{' '}
182+
{data.original_title_romanised ? `(${data.original_title_romanised})` : ''}
183+
</p>
184+
)}
185+
</div>
186+
{data.rt_score && <div style={scoreStyle}>{data.rt_score}</div>}
187+
</div>
188+
189+
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '12px', marginTop: '8px' }}>
190+
{data.producer && <div style={subtitleStyle}>Producer: {data.producer}</div>}
191+
{data.director && <div style={subtitleStyle}>Director: {data.director}</div>}
192+
</div>
193+
194+
{data.description && <p style={descStyle}>{data.description}</p>}
195+
196+
{children}
197+
</div>
198+
</div>
199+
</div>
200+
)
201+
202+
if (typeof document !== 'undefined' && document.body)
203+
return createPortal(modalNode, document.body)
204+
return modalNode
205+
}

src/pages/Movies.jsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,16 @@ import ErrorMessage from '../components/ErrorMessage.jsx';
2323
import Card from '../components/Card.jsx';
2424
import HeroSection from '../components/HeroSection';
2525
import Cinema from '../Images/Movie.jpg';
26+
import Modal from '../components/Modal.jsx';
2627

2728
export default function Movies() {
2829
const [films, setFilms] = useState([]);
2930
const [error, setError] = useState(null);
3031
const [loading, setLoading] = useState(false);
3132
const [filter, setFilter] = useState('');
33+
const [isModalOpen, setIsModalOpen] = useState(false);
34+
const [selectedFilm, setSelectedFilm] = useState(null);
35+
3236

3337
useEffect(() => { fetchFilms(); }, []);
3438

@@ -44,6 +48,16 @@ export default function Movies() {
4448

4549
const filtered = films.filter(f => f.director.toLowerCase().includes(filter.toLowerCase()) || f.release_date.includes(filter));
4650

51+
const openModal = (film) => {
52+
setSelectedFilm(film);
53+
setIsModalOpen(true);
54+
};
55+
56+
const closeModal = () => {
57+
setIsModalOpen(false);
58+
setSelectedFilm(null);
59+
};
60+
4761
return (
4862
<div>
4963
<HeroSection
@@ -61,13 +75,28 @@ export default function Movies() {
6175
<ErrorMessage error={error} />
6276
<div className="grid">
6377
{filtered.map(f => (
64-
<Card key={f.id} title={`${f.title} (${f.release_date})`} japaneseTitle={f.original_title} image={f.image}>
65-
<p><strong>Director:</strong> {f.director}</p>
66-
<p>{f.description.slice(0,120)}...</p>
67-
{/* TODO: Add poster images (need mapping) */}
68-
</Card>
78+
<div
79+
key={f.id}
80+
type="button"
81+
onClick={() => openModal(f)}
82+
aria-label={`Open details for ${f.title}`}
83+
style={{
84+
display: 'contents',
85+
cursor: 'pointer',
86+
}}
87+
>
88+
<Card title={`${f.title} (${f.release_date})`} japaneseTitle={f.original_title} image={f.image}>
89+
<p><strong>Director:</strong> {f.director}</p>
90+
<p>{f.description.slice(0,120)}...</p>
91+
{/* TODO: Add poster images (need mapping) */}
92+
</Card>
93+
</div>
94+
6995
))}
7096
</div>
97+
{isModalOpen && selectedFilm && (
98+
<Modal open={isModalOpen} onClose={closeModal} film={selectedFilm} />
99+
)}
71100
</div>
72101
);
73-
}
102+
}

0 commit comments

Comments
 (0)