【實作記錄】Netflix Clone:Browser頁

Live Demo
Github

簡介

Browser 頁處理的東西比較多:用戶 Profile 選擇、首頁大圖、每列分類以及卡片、卡片詳細資訊、播放器、搜尋功能、sign out 功能。series 以及 films 的資料都存在 firestore 裡,之後有餘力會再多增加幾筆資料做換頁效果。

Part 1: 頁頭功能 Browser header

Brower header 分為三個部分處理 : 右側功能、左側功能、以及首頁大圖。


Browser 右側功能

browser 右側 有 Logo, Catagory link, Series Link ,以及一個 container: Group. 可以使用之前創造好的 header component,但缺少的元件要回去 header (index.js) 裡補上。

browse.js (containers) >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import React, { useState, useContext } from "react"
import { Header } from "../components"
import { FirebaseContext } from "../context/firebase"
import { FooterContainer } from "./footer"
import { SelectProfileContainer } from "./profiles"
import * as ROUTES from "../constants/routes"

function BrowseContainer(){
const [ profile, setProfile ] = useState({})
const [ category, setCategory ] = useState("series")
const [ loading, setLoading ] = useState(true)

const { firebase } = useContext(FirebaseContext)

const user = {
displayName: 'Karl',
photoURL:"1"
}

return profile.displayName ? (
<>
<Header src="joker1" dontShowOnSmallViewPort>
<Header.Frame>
<Header.Group>
<Header.Logo
to={ROUTES.HOME}
src="images/misc/logo.png"
alt="Netflix"/>

<Header.Link
active ={category === "series"? "true":"false"}
onClick={() => setCategory("series")}
>
Series
</Header.Link>

<Header.Link
active ={category === "films"? "true":"false"}
onClick={() => setCategory("films")}
>
Film
</Header.Link>
</Header.Group>
</Header.Frame>
</Header>
<FooterContainer/>
</>
)
: (<SelectProfileContainer user={user} setProfile={ setProfile }/>)

}

export { BrowseContainer }

第 21 行 : 如果有 display name,就進入 browser ,無則顯示用戶選擇頁
第 23 行 : 引入 Header, 傳入首頁大圖,設定小熒幕不呈現

第 25 行 : container 的概念,用來調整樣式
第 31, 38 行 : 設定 link, 使用 state 來改變 active的狀態,active 用來調整樣式

index.js (components > header) >folded
1
2
3
4
5
6
7
 Header.Link = function HeaderLink({ children, ...restProps }){
return <Link {...restProps}>{children}</Link>
}

Header.Group = function HeaderGroup({ children, ...restProps }) {
return <Group {...restProps}>{children}</Group>
}
header.js (components > header > styles) >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export const Link = styled.p`
color: #fff;
text-decoration: none;
margin-right: 30px;
font-weight: ${({ active }) => (active === 'true' ? '700' : 'normal')};
cursor: pointer;

&:hover {
font-weight: bold;
}
&:last-of-type {
margin-right: 0;
}
`

export const Group = styled.div`
display: flex;
align-items: center;
`


Browser 首頁大圖

Browser 首頁大圖除了電影海報、會加上介紹,Play Button,info button.其中 Play Button 在點擊後會連到電影預告 (Youtube). Header 會再加上 Feature, FeatureCallOut, Text, Span, Play Button, Info Button, 因此 index.js (header) 要增加這幾個元件。

browse.js (containers) >folded
1
2
3
4
5
6
7
8
9
10
<Header.Feature> //container的概念
<Header.FeatureCallOut>Extraction</Header.FeatureCallOut>
<Header.Text>
A black-market mercenary who has nothing to lose is hired to rescue the kidnapped son of an imprisoned international crime lord. But in the murky underworld of weapons dealers and drug traffickers, an already deadly mission approaches the impossible.
</Header.Text>

<Header.Span>
<Header.PlayButton></i>more info</Header.InfoButton>
</Header.Span>
</Header.Feature>
index.js (components > header) >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Header.Text = function HeaderText({ children, ...restProps }){
return <Text {...restProps}>{children}</Text>
}

Header.Feature = function HeaderFeature({ children, ...restProps }){
return <Feature {...restProps}>{children}</Feature>
}

Header.FeatureCallOut = function HeaderFeatureCallOut({ children, ...restProps }){
return <FeatureCallOut {...restProps}>{children}</FeatureCallOut>
}

Header.InfoButton = function HeaderInfoButton({ children, ...restProps }){
return <InfoButton {...restProps}>{children}</InfoButton>
}

Header.PlayButton = function HeaderPlayButton({ children, ...restProps }){
return <PlayButton {...restProps}>{children}</PlayButton>
}

Header.Span = function HeaderSpan({ children, ...restProps }){
return <Span {...restProps}>{children}</Span>
}

點擊 search icon 搜索框就會跳出來,在小於700px的熒幕不提供搜尋功能。跳出來的功能是靠改變 state 來完成的,點擊按鈕 active 變成 true,在sytled component 裡有 active 與不 active 相應的樣式。

browse.js (containers) >folded
1
2
3
<Header.Group>
<Header.Search searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
</Header.Group>
index.js (components > header) >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

//filename:index.js (components > header)
Header.Search = function HeaderSearch({ searchTerm, setSearchTerm, ...restProps }){
const [ searchActive, setSearchActive ] = useState(false)

return(
<Search {...restProps}>
<SearchIcon onClick={() => setSearchActive(!searchActive)}>
<i className="fas fa-search"></i>
</SearchIcon>

<SearchInput
value={searchTerm}
onChange={({ target }) => setSearchTerm(target.value)}
placeholder="Search files and series"
active={searchActive}
/>
</Search>
)
}
header.js (conponents > header) >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// active 時候 input 的設法

export const SearchInput = styled.input`
background-color: rgba(105, 105, 105, 0.3);
color: #000;
border: 1px solid #fff;
transition: width 0.5s;
height: 30px;
font-size: 14px;
margin-left: ${({ active }) => (active === true ? '10px' : '0')};
padding: ${({ active }) => (active === true ? '0 10px' : '0')};
opacity: ${({ active }) => (active === true ? '1' : '0')};
width: ${({ active }) => (active === true ? '200px' : '0px')};
`

Browser 左側功能 : 下拉式選單 + 其他 icon

Dropdown 選單:Profile,Account, Help center, Sign Out. 並排的 KIDs icon, gift icon, bell icon 暫時沒有功能。Dropdown 功能是 hover 的時候就會出現,因此是在 styled component 中處理,沒有特別使用 state 來做。

browse.js >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<Header.Group>
<Header.Search searchTerm={searchTerm} setSearchTerm={setSearchTerm} />

//其他icon

<Header.Span>
<Header.Text>KIDS</Header.Text>
<i className="fas fa-gift" ></i>
<i className="fas fa-bell" ></i>
</Header.Span>

// 點擊頭像
<Header.Profile>
<Header.Picture src={user.photoURL} />
// 下拉式選單
<Header.Dropdown>
<Header.Group>
<Header.Picture src={user.photoURL}/>
<Header.Text>{user.displayName}</Header.Text>
</Header.Group>
<Header.Group>
//選單
<Header.Span>
<Header.Link >Account</Header.Link>
<Header.Link >Help Center</Header.Link>
<Header.Link onClick={() => firebase.auth().signOut()}>Sign Out</Header.Link>
</Header.Span>
</Header.Group>
</Header.Dropdown>
</Header.Profile>
</Header.Group>

index.js (components > header) >folded
1
2
3
4
5
6
7
8
9
10
11
Header.Profile = function HeaderProfile({ children, ...restProps }){
return <Profile {...restProps}>{children}</Profile>
}

Header.Dropdown = function HeaderDropdown({ children, ...restProps }){
return <Dropdown {...restProps}>{children}</Dropdown>
}

Header.Picture = function HeaderPicture({ src, ...restProps }){
return <Picture {...restProps} src={`照片位置`}/>
}

Part 2: 資料處理

提取 firebase 裡的資料

使用下面的方法提取 collection 裡的資料。創造一個 state 來儲存需要的資料;只需要在 firebase 改變的時候獲取一次資料,所以使用 useEffect. 最後可以印出獲取的資料驗證是否有取得需要的資料。

use-content (hooks) >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { useContext, useEffect, useState } from "react"
import { FirebaseContext } from "../context/firebase"

function useContent(target) {
const { firebase } = useContext(FirebaseContext)
const [ content, setContent ] = useState([])

//提取 collection 資料的方式
useEffect(() => {
firebase
.firestore()
.collection(target)
.get()
.then((snapshot) => {
const allContent = snapshot.docs.map((contentObj) => ({
...contentObj.data(),
docID : contentObj.id
}))

setContent(allContent)
})

.catch((error) => {
console.error(error)
})
}, [])

return { [target] : content }
}

export default useContent

這裡在 Browse.js 中嘗試印出。

Browse.js (pages) >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React from "react"
import { BrowseContainer } from "../containers/browse"
import { useContent } from "../hooks"

function Browse() {
const { series } = useContent('series')
const { film } = useContent('film')

console.log(series)
console.log(film)

return <BrowseContainer/>
}

export default Browse


Part 3: 電影、電視列表 film and series list

電影列表會模仿 netflix 的樣式. 暫時會分為 series 和 films 兩頁,用戶可以自由切換頁面。每頁會有 4-5 個清單,每個清單的卡片都可以打開查看資訊,會出現照片、關閉 button, play button, add to my list button. 卡片的資料來源是前一個 part 獲得的 slide 資料。

slide 資料的組成:

Slide 的元件 (畫面中有好幾條不同類別的 list )

用戶可以切換頁面使用 useState 和 useEffect 來控制,在 category 改變的時候就調用該 category 的資料,點擊 film, state 會更新,提取的資料就更換成 film.

調出每一筆資料則是使用 .map() 遍歷 slide 資料。先遍歷被選中的 categroty 底下的資料, 提取每一個類別的分類 (title), 再遍歷每一筆資料 (data), 調出每一個分類下的影片資訊。

brwose.js (containers) >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<Card.Group>
{slideRows.map((slideItem) => (
<Card key={`${category}-${slideItem.title.toLowerCase()}`}>
<Card.Title>{slideItem.title}</Card.Title>
<Card.Entities>
{slideItem.data.map((item) => (
//src 要加上``
<Card.Item key={item.docid} item={item}>
<Card.Img src={/images/${category}/${item.genre}/${item.slug}/small.jpg} />
<Card.Meta>
<Card.Subtitle>{item.title}</Card.Subtitle>
<Card.Text>{item.description}</Card.Text>
</Card.Meta>
</Card.Item>
))}
</Card.Entities>
</Card>
))}
</Card.Group>

建立 card component

每張卡片點進去會跳出詳細資訊的區塊稱為 “Feature”. Card component 裡會創造 context 以及 state. 每張 card 是一個 item,這設置兩組 state:一組為點擊 card 就更新 state 為被點擊 card 的資料;另一組為點擊 card 後更新 state 為 true,讓卡片彈跳出來。由於有超過一個地方需要這兩個 state 因此使用 context 包起來。

index.js (components > card) >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import React, { useState, useContext, createContext } from "react"

import {
Container,
Group,
Title,
Item,
Entities,
Image,
Meta,
Subtitle,
Text,
Feature,
FeatureTitle,
FeatureClose,
Maturity,
Content,
} from "./styles/card"

const FeatureContext = createContext()

function Card({ children, ...restProps }) {
const [showFeature, setShowFeature] = useState(false)
const [itemFeature, setitemFeature] = useState(false)

//value 的大括號中要再放進大括號

return(
<FeatureContext.Provider value={}>
<Container {...restProps }>{children}</Container>
</FeatureContext.Provider>
)
}

Card.Group = function CardGroup({children, ...restProps}){
return <Group {...restProps}>{children}</Group>
}

Card.Title = function CardTitle({children, ...restProps}){
return <Title {...restProps}>{children}</Title>
}

Card.Subtitle = function CardSubtitle({children, ...restProps}){
return <Subtitle {...restProps}>{children}</Subtitle>
}

Card.Text = function CardText({children, ...restProps}){
return <Text {...restProps}>{children}</Text>
}

Card.Entities = function CardEntities({children, ...restProps}){
return <Entities {...restProps}>{children}</Entities>
}

Card.Meta = function CardMeta({children, ...restProps}){
return <Meta {...restProps}>{children}</Meta>
}

Card.Item = function CardItem({item, children, ...restProps}){
const { setitemFeature, setshowFeature } = useContext(FeatureContext)

return (
<Item
onClick={() => {
setitemFeature(item) //itemFeature 就是整個 item
setshowFeature(true)
}}
{...restProps}>{children}</Item>
)
}

Card.Image = function CardEntities({...restProps}){
return <Image {...restProps}/>
}

Card.Feature = function CardFeature({children, category ,...restProps}){
const { showFeature, setShowFeature, itemFeature } = useContext(FeatureContext)

return showFeature ? (
<Feature src={`images/${category}/${itemFeature.genre}/${itemFeature.slug}/large.jpg}`}>
<Content>
<Feature.Title>{itemFeature.title}</Feature.Title>
<Feature.Text>{itemFeature.description}</Feature.Text>
<Feature.Close
onClick={()=>setShowFeature(false)}
>
<i className="fas fa-times"></i>
</Feature.Close>

<Group margin="30px 0" flexDirection="row" alignItems="center">
<Maturity rating={itemFeature.maturity}>{itemFeature.Maturity < "12" ? "PG": itemFeature.Maturity}</Maturity>
<Feature.Text fontWeight="bold">{itemFeature.genre.charAt(0).toUpperCase() + itemFeature.genre.slice(1)}</Feature.Text>
</Group>
</Content>
{children}
</Feature>
) : null
}

export default Card
  • 第 22 行 : 創造 context
  • 第 25 行 : 創造 state ( feature 是否出現 )
  • 第 26 行 : 創造 state ( feature 的內容為被點擊的卡片的內容 )
  • 第 29 行 : 用 provider 包起來,之後就可以 consume
  • 第 59 行 : 會傳進 item, item 是 browse.js 中的item

  • 第 60 行 : 用 useContext 傳進需要的參數
  • 第 65 行 : 點擊後,把 itemFeature 改成被點擊的卡片的內容
  • 第 66 行 : 點擊後,把 showFeature 改成 true, 顯示 feature 框框
  • 第 76 行 : Card.Feature 整組是點擊 card 後跳出來的 feature 框框
  • 第 79 行 : 如果 showFeature 為 true 才顯示 feature 框框
  • 第 80 行 : 背景會壓一張照片
  • 第 84 行 : 點擊後,把 showFeature 改成 false, feature 框框收起來
  • 第 90 行 : 在 style 的時候可以根據參數進行處理
  • 第 91 行 : 如果資料的 maturity 小於 12 就顯示 PG, 若非則顯示 資料上的數字
  • 第 92 行 : genre 首字母為大字母


參考資料

Fuse.js
search box 提供用戶搜尋的功能。這個功能使用 Fuse.js 這個套件完成。

CustomPagination.js
1
npm install --save fuse.js

搜尋功能會在 browse.js 中完成, 之前已經在 search component 中加入 search 的 state. 使用 useeffect ,在這個 state 改變的時候就會執行設定好的程式碼。

browse.js >folded
1
2
const [searchTerm, setSearchTerm] = useState('')
<Header.Search searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
browse.js >folded
1
2
3
4
5
6
7
8
9
10
useEffect(() => {
const fuse = new Fuse(slideRows, { keys: ['data.description', 'data.title', 'data.genre'] });
const results = fuse.search(searchTerm).map(({ item }) => item);

if (slideRows.length > 0 && searchTerm.length > 3 && results.length > 0) {
setSlideRows(results);
} else {
setSlideRows(slides[category]);
}
}, [searchTerm])
  • 第 4 行 : 設定搜尋條件
  • 第 5 行 : fuse 搜尋功能的寫法
  • 第 7 行 : 設定結果條件

Part 5: 播放器 Player

播放器按鈕會出現在 feature 框框出現,點擊後就會彈出播放器。但資料庫沒有每個卡片的影片,因此這裡僅是示範。最後版本會移除此功能。

播放器按鈕一樣是依賴 state 來操作。這個 state 會在多過一處使用,因此可以使用 useContext 包起來。Video 直接透過 reactDOM 渲染到瀏覽器,不經過 browse container/ 其他 container 渲染。

index.js >folded
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import React, { useState, useContext, createContext} from "react"
import ReactDOM from "react-dom"
import { Container, Overlay, Inner, Button } from "./styles/player"

export const PlayerContext = createContext()

function Player({ children, ...restProps }) {
const [showPlayer, setShowPlayer] = useState(false)

//value = {showPlayer, setShowPlayer}

return(
<PlayerContext.Provider value={}>
<Container {...restProps}>{children}</Container>
</PlayerContext.Provider>
)
}

Player.Video = function PlayerVideo({ ...restProps }) {
const { showPlayer, setShowPlayer } = useContext(PlayerContext);

return showPlayer
? ReactDOM.createPortal(
<Overlay onClick={() => setShowPlayer(false)}>
<Inner>
<video id="netflix-player" controls>
<source src="/videos/bunny.mp4" type="video/mp4" />
</video>
</Inner>
</Overlay>,
document.body
) : null;
}

Player.Button = function PlayerButton({ ...restProps }) {
const { showPlayer, setShowPlayer } = useContext(PlayerContext);

return <Button onClick={() => setShowPlayer(!showPlayer)}>Play</Button>;
}

export default Player

第 13 行 : 使用 Provider 包起來
第 20 行 : consume provieder
第 23 行 : 使用 createPortal

browse.js (containers) >folded
1
2
3
4
5
6
7
<Card.Feature category={category}>
<Player>
<Player.Button />
<Player.Video />
</Player>
</Card.Feature>

導覽頁

Netflix Clone : 主頁
Netflix Clone : 首頁
Netflix Clone : 用戶登入頁
Netflix Clone : Browser 頁
Netflix Clone : 最後整理

Comments