Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,41 @@
# Movies
# Sara and Marinas Movie app

Description: This project is a responsive movie app built with React. The app shows a list of popular movies and allows users to click on a movie-card to view details on the specific movie.

# What we practiced

- We practiced using React Router, building a two-page app.
- Fetching data from an external API
- Working with state and effects in React
- Applying Accessibility
- Working with pair programming

* We started the project by working together, setting up a structure, how we wanted to divide the work and how we wanted to design it. We split the two pages between us and worked on them separately. Finally we merges the pages together.
* We are well aware that we forgot to commit, hence only one commit. But our work should still be traceable in our various files. We will take this with us to the next project, to be more careful with committing.

# Tech used

- React and React Router
- Styled components
- Theme and Global Styles
- The Move Database (TMDB) API
- JavaScript
- HTML
- CSS

# How the app works

- Homepage displays a grid of popular movies, every card shows image and title
- Every card is clickable and navigates to a movie detail page
- Detail page shows:
- Title
- Rating
- Genres
- Release year
- Runtime
- The app is fully responsive in mobile, tablet and desktop
- The app is also accessible with semantic HTML, alt texts on images and contrasts.

* Accessibility on Lighthouse: 99%

# Live demo:https://project-js-movieapp.netlify.app/
13 changes: 8 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta
name="description"
content="A fun React app showing popular movies using tmdbAPI"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Movies</title>
</head>
<body>
<div id="root"></div>
<script
type="module"
src="./src/main.jsx">
</script>
<main>
<div id="root"></div>
<script type="module" src="./src/main.jsx"></script>
</main>
</body>
</html>
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.562.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-router-dom": "^7.11.0",
"styled-components": "^6.1.19"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^4.7.0",
"babel-plugin-styled-components": "^2.1.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
Expand Down
2 changes: 1 addition & 1 deletion pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Please include your Netlify link here.
Netlify link: https://project-js-movieapp.netlify.app/
20 changes: 17 additions & 3 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import React from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { ThemeProvider } from "styled-components";
import { theme } from "./theme";
import { HomePage } from "./pages/HomePage";
import { DetailsPage } from "./pages/DetailsPage";

export const App = () => {
return (
<h1>Movies</h1>
)
}
<ThemeProvider theme={theme}>
<BrowserRouter>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/movies/:id" element={<DetailsPage />} />
</Routes>
</BrowserRouter>
</ThemeProvider>
);
};
31 changes: 31 additions & 0 deletions src/components/BackLink.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import styled from "styled-components";
import { Link } from "react-router-dom";

export const BackLink = styled(Link)`
position: absolute;
top: 0;
left: 0;
z-index: 10;

display: flex;
align-items: center;
gap: ${(props) => props.theme.spacing.sm};

padding: ${(props) => props.theme.spacing.lg};
margin: ${(props) => props.theme.spacing.lg};

color: ${(props) => props.theme.colors.text.primary};
font-size: ${(props) => props.theme.fontSize.lg};

background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
border-radius: ${(props) => props.theme.borderRadius.md};

transition: color ${(props) => props.theme.transitions.normal},
background ${(props) => props.theme.transitions.normal};

&:hover {
color: ${(props) => props.theme.colors.hover};
background: rgba(0, 0, 0, 0.7);
}
`;
245 changes: 245 additions & 0 deletions src/components/DetailCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import React from "react";
import styled, { useTheme } from "styled-components";
import { Star } from "lucide-react";

export const DetailCard = ({ movie }) => {
const theme = useTheme();
const { api } = theme;

const posterUrl = movie?.poster_path
? `${api.imageBaseUrl}${api.imageSizes.poster.tablet}${movie.poster_path}`
: null;

const backdropUrl = movie?.backdrop_path
? `${api.imageBaseUrl}${api.imageSizes.backdrop.desktop}${movie.backdrop_path}`
: null;

const releaseYear = movie?.release_date
? new Date(movie.release_date).getFullYear()
: "N/A";

const genres = movie?.genres?.length
? movie.genres.map((g) => g.name).join(", ")
: "N/A";

const runtime = movie?.runtime ? `${movie.runtime} min` : "N/A";

return (
<BackdropContainer $backdropUrl={backdropUrl}>
<BackdropOverlay />

<ContentWrapper>
<PosterSection>
{posterUrl && (
<PosterImage
src={posterUrl}
alt={`${movie.title} movie poster`}
width="350"
height="525"
loading="lazy"
/>
)}
</PosterSection>

<DetailsSection>
<TitleWrapper>
<Title>{movie?.title ?? "Untitled"}</Title>

<RatingBadge>
<Star size={24} fill="currentColor" />
<RatingText>
{movie?.vote_average
? Math.round(movie.vote_average * 10) / 10
: "N/A"}
</RatingText>
</RatingBadge>
</TitleWrapper>

{movie?.tagline && <Tagline>{movie.tagline}</Tagline>}

{movie?.overview && <Overview>{movie.overview}</Overview>}

<MovieDetails>
<DetailItem>
<DetailLabel>Release Year:</DetailLabel>
<DetailValue>{releaseYear}</DetailValue>
</DetailItem>

<DetailItem>
<DetailLabel>Runtime:</DetailLabel>
<DetailValue>{runtime}</DetailValue>
</DetailItem>

<DetailItem>
<DetailLabel>Genres:</DetailLabel>
<DetailValue>{genres}</DetailValue>
</DetailItem>
</MovieDetails>
</DetailsSection>
</ContentWrapper>
</BackdropContainer>
);
};

const BackdropContainer = styled.div`
position: relative;
min-height: 100vh;
background-image: ${(props) =>
props.$backdropUrl ? `url(${props.$backdropUrl})` : "none"};
background-size: cover;
background-position: center;
background-repeat: no-repeat;
`;

const BackdropOverlay = styled.div`
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgba(0, 0, 0, 0.95) 0%,
rgba(0, 0, 0, 0.85) 40%,
rgba(0, 0, 0, 0.4) 70%,
rgba(0, 0, 0, 0.2) 100%
);

@media (max-width: ${(props) => props.theme.breakpoints.tablet}) {
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.7) 0%,
rgba(0, 0, 0, 0.9) 50%,
rgba(0, 0, 0, 0.95) 100%
);
}
`;

const ContentWrapper = styled.div`
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: ${(props) => props.theme.spacing.xl};
padding: ${(props) => props.theme.spacing.xl};
padding-top: calc(${(props) => props.theme.spacing.xxxl} + 60px);
max-width: ${(props) => props.theme.breakpoints.wide};
margin: 0 auto;
min-height: 100vh;

@media (min-width: ${(props) => props.theme.breakpoints.tablet}) {
flex-direction: row;
align-items: flex-start;
padding: ${(props) => props.theme.spacing.xxl};
padding-top: calc(${(props) => props.theme.spacing.xxxl} + 80px);
}
`;

const PosterSection = styled.div`
flex-shrink: 0;
width: 100%;
max-width: 350px;

@media (min-width: ${(props) => props.theme.breakpoints.tablet}) {
width: 350px;
}
`;

const PosterImage = styled.img`
width: 100%;
height: auto;
border-radius: ${(props) => props.theme.borderRadius.lg};
box-shadow: ${(props) => props.theme.shadows["2xl"]};
border: 4px solid rgba(255, 255, 255, 0.1);
`;

const DetailsSection = styled.div`
flex: 1;
display: flex;
flex-direction: column;
gap: ${(props) => props.theme.spacing.lg};
max-width: 800px;
`;

const TitleWrapper = styled.div`
display: flex;
align-items: flex-start;
gap: ${(props) => props.theme.spacing.lg};
flex-wrap: wrap;
`;

const Title = styled.h1`
flex: 1;
font-size: ${(props) => props.theme.fontSize["4xl"]};
font-weight: ${(props) => props.theme.fontWeight.bold};
color: ${(props) => props.theme.colors.text.primary};
line-height: 1.2;
min-width: 250px;

@media (min-width: ${(props) => props.theme.breakpoints.tablet}) {
font-size: ${(props) => props.theme.fontSize["5xl"]};
}
`;

const RatingBadge = styled.div`
display: flex;
align-items: center;
gap: ${(props) => props.theme.spacing.sm};
background-color: ${(props) => props.theme.colors.accent.yellow};
color: ${(props) => props.theme.colors.accent.yellowText};
padding: ${(props) => props.theme.spacing.md}
${(props) => props.theme.spacing.lg};
border-radius: ${(props) => props.theme.borderRadius.lg};
font-weight: ${(props) => props.theme.fontWeight.bold};
box-shadow: ${(props) => props.theme.shadows.lg};
`;

const RatingText = styled.span`
font-size: ${(props) => props.theme.fontSize["2xl"]};
`;

const Tagline = styled.p`
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.fontSize.xl};
font-style: italic;
margin-top: -${(props) => props.theme.spacing.md};
`;

const Overview = styled.p`
font-size: ${(props) => props.theme.fontSize.lg};
color: ${(props) => props.theme.colors.text.secondary};
line-height: 1.8;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);

@media (min-width: ${(props) => props.theme.breakpoints.tablet}) {
font-size: ${(props) => props.theme.fontSize.xl};
}
`;

const MovieDetails = styled.div`
display: flex;
flex-direction: column;
gap: ${(props) => props.theme.spacing.md};
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10px);
padding: ${(props) => props.theme.spacing.lg};
border-radius: ${(props) => props.theme.borderRadius.md};
border: 1px solid rgba(255, 255, 255, 0.1);
`;

const DetailItem = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${(props) => props.theme.spacing.sm};
`;

const DetailLabel = styled.span`
color: ${(props) => props.theme.colors.text.label};
font-size: ${(props) => props.theme.fontSize.md};
font-weight: ${(props) => props.theme.fontWeight.semibold};
text-transform: uppercase;
letter-spacing: 0.05em;
`;

const DetailValue = styled.span`
color: ${(props) => props.theme.colors.text.primary};
font-size: ${(props) => props.theme.fontSize.md};
`;
Loading