├── .eslintrc.json
├── .gitignore
├── .npmrc
├── .prettierrc
├── .vscode
├── extensions.json
├── kiba-utils.code-snippets
└── launch.json
├── README.md
├── astro.config.mjs
├── package-lock.json
├── package.json
├── public
├── favicon.ico
└── styles
│ ├── global.css
│ └── utils.css
├── src
├── arts
│ └── NarutoMainArt.tsx
├── components
│ ├── Movie
│ │ ├── Movie.css
│ │ └── Movie.tsx
│ ├── MovieItem
│ │ ├── MovieItem.css
│ │ └── MovieItem.tsx
│ ├── Movies
│ │ ├── Movies.css
│ │ └── Movies.tsx
│ ├── Trailer
│ │ ├── Trailer.css
│ │ └── Trailer.tsx
│ ├── index.ts
│ └── navigation
│ │ ├── Navigation.css
│ │ └── Navigation.tsx
├── env.d.ts
├── icons
│ ├── BackButton.tsx
│ ├── Backward.tsx
│ ├── Forward.tsx
│ ├── FullScreen.tsx
│ ├── MinimizeScreen.tsx
│ ├── MuteTrailer.tsx
│ ├── Play.tsx
│ ├── Replay.tsx
│ ├── SoundTrailer.tsx
│ ├── Stop.tsx
│ ├── VolumeEmpty.tsx
│ └── VolumeFull.tsx
├── layouts
│ └── MySiteLayout.astro
├── lib
│ ├── constants.ts
│ ├── schemas.ts
│ ├── types.ts
│ └── utils.ts
├── pages
│ ├── [id].astro
│ └── index.astro
└── styles
│ ├── movie.css
│ └── trailer.css
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "eslint:recommended",
4 | "plugin:@typescript-eslint/recommended",
5 | "plugin:import/typescript"
6 | ],
7 | "plugins": ["@typescript-eslint", "import"],
8 | "rules": {
9 | "@typescript-eslint/consistent-type-imports": "error",
10 | "@typescript-eslint/ban-ts-comment": "warn",
11 | "no-await-in-loop": "error",
12 | "import/order": [
13 | "error",
14 | {
15 | "newlines-between": "always",
16 | "groups": [
17 | "type",
18 | "builtin",
19 | "external",
20 | ["parent", "sibling"],
21 | "index"
22 | ],
23 | "alphabetize": {
24 | "order": "asc",
25 | "caseInsensitive": true
26 | }
27 | }
28 | ]
29 | },
30 | "ignorePatterns": ["**/node_modules/**", "**/dist/**", "**/build/**"]
31 | }
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 |
4 | # dependencies
5 | node_modules/
6 |
7 | # logs
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | pnpm-debug.log*
12 |
13 |
14 | # environment variables
15 | .env
16 | .env.production
17 |
18 | # macOS-specific files
19 | .DS_Store
20 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | # Expose Astro dependencies for `pnpm` users
2 | shamefully-hoist=true
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "always",
3 | "bracketSpacing": true,
4 | "embeddedLanguageFormatting": "auto",
5 | "htmlWhitespaceSensitivity": "css",
6 | "insertPragma": false,
7 | "jsxBracketSameLine": false,
8 | "jsxSingleQuote": false,
9 | "printWidth": 80,
10 | "proseWrap": "preserve",
11 | "quoteProps": "as-needed",
12 | "requirePragma": false,
13 | "semi": false,
14 | "singleQuote": true,
15 | "tabWidth": 2,
16 | "trailingComma": "es5",
17 | "useTabs": false,
18 | "vueIndentScriptAndStyle": false
19 | }
20 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["astro-build.astro-vscode"],
3 | "unwantedRecommendations": []
4 | }
5 |
--------------------------------------------------------------------------------
/.vscode/kiba-utils.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | // Place your naruto-wiki workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
7 | // Placeholders with the same ids are connected.
8 | // Example:
9 | // "Print to console": {
10 | // "scope": "javascript,typescript",
11 | // "prefix": "log",
12 | // "body": [
13 | // "console.log('$1');",
14 | // "$2"
15 | // ],
16 | // "description": "Log output to console"
17 | // }
18 | "Write calculated font size": {
19 | "scope": "css",
20 | "prefix": "fnz",
21 | "body": ["font-size: calc(1rem * $1 / 16);"],
22 | "description": "Write the font size which calculates pixels and rem divided. Used to better understand the size of the font, but still let them be accessible as rems in the browser."
23 | },
24 | "Write media query": {
25 | "scope": "css",
26 | "prefix": "mdq",
27 | "body": ["@media screen and (min-width: $1) {$2}"],
28 | "description": "Write the font size which calculates pixels and rem divided. Used to better understand the size of the font, but still let them be accessible as rems in the browser."
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "command": "./node_modules/.bin/astro dev",
6 | "name": "Development server",
7 | "request": "launch",
8 | "type": "node-terminal"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | A Mini Netflix clone built with Solid.js, Astro and deployed on Netlify. :smile:
2 |
3 | I'm using Directus as a headless CMS here, though, I'm using the free version, so the site may not be available for everyone after people have visited it.
4 |
5 | I couldn't upload the original demo video with sound because it is too big. :skull:
6 |
7 | Regardless, here is the muted and speeded up version. :fire:
8 |
9 | https://user-images.githubusercontent.com/49603590/188299753-a07d2a6d-7a50-4894-baf2-2499acec1e8e.mp4
10 |
--------------------------------------------------------------------------------
/astro.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'astro/config'
2 | import netlify from '@astrojs/netlify/functions'
3 | import solid from '@astrojs/solid-js'
4 |
5 | // https://astro.build/config
6 | export default defineConfig({
7 | // Enable Solid to support Solid JSX components.
8 | integrations: [solid()],
9 | output: 'server',
10 | adapter: netlify(),
11 | })
12 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "kiba",
3 | "version": "0.0.1",
4 | "private": true,
5 | "author": {
6 | "name": "Tiger Abrodi",
7 | "email": "tigerabrodi@gmail.com"
8 | },
9 | "license": "MIT",
10 | "scripts": {
11 | "dev": "astro dev",
12 | "start": "astro dev",
13 | "build": "astro build",
14 | "preview": "astro preview",
15 | "astro": "astro"
16 | },
17 | "devDependencies": {
18 | "@astrojs/solid-js": "^1.0.0",
19 | "@types/uuid": "^8.3.4",
20 | "astro": "^1.0.5",
21 | "prettier": "^2.7.1",
22 | "prettier-plugin-astro": "^0.5.0"
23 | },
24 | "dependencies": {
25 | "@astrojs/netlify": "^1.0.2",
26 | "@fontsource/montserrat": "^4.5.12",
27 | "solid-js": "^1.4.3",
28 | "uuid": "^8.3.2",
29 | "zod": "^3.18.0"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tigerabrodi/kiba/b389d42bf0a3e08f66e5b7d1f8bee6ee2c700a97/public/favicon.ico
--------------------------------------------------------------------------------
/public/styles/global.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --colors-brown: #141414;
3 | --colors-black: black;
4 | --colors-white: white;
5 | --colors-grey: #c7c7c7;
6 | }
7 |
8 | *,
9 | *::after,
10 | *::before {
11 | margin: 0;
12 | padding: 0;
13 | box-sizing: inherit;
14 | font-family: 'Helvetica';
15 | }
16 |
17 | html {
18 | height: 100%;
19 | }
20 |
21 | body {
22 | box-sizing: border-box;
23 | background-color: var(--colors-brown);
24 | display: flex;
25 | flex-direction: column;
26 | align-items: center;
27 | }
28 |
29 | button {
30 | cursor: pointer;
31 | border: none;
32 | }
33 |
34 | a:focus-visible,
35 | button:focus-visible {
36 | outline: 1px solid white;
37 | outline-offset: 2px;
38 | box-shadow: 0 0 0 5px black;
39 | }
40 |
--------------------------------------------------------------------------------
/public/styles/utils.css:
--------------------------------------------------------------------------------
1 | .sr-only {
2 | position: absolute;
3 | left: -10000px;
4 | width: 1px;
5 | height: 1px;
6 | top: auto;
7 | overflow: hidden;
8 | }
9 |
--------------------------------------------------------------------------------
/src/arts/NarutoMainArt.tsx:
--------------------------------------------------------------------------------
1 | import type { ClassProps } from '../lib/types'
2 | import { generateUniqueIds } from '../lib/utils'
3 |
4 | export function NarutoMainArt(props: ClassProps) {
5 | const uniqueIdsForUrlsInSVG = generateUniqueIds(6)
6 |
7 | return (
8 |
466 | )
467 | }
468 |
--------------------------------------------------------------------------------
/src/components/Movie/Movie.css:
--------------------------------------------------------------------------------
1 | .movie__container {
2 | height: 100%;
3 | width: 100%;
4 | position: relative;
5 | }
6 |
7 | .movie__video {
8 | position: absolute;
9 | top: 0;
10 | left: 0;
11 | width: 100%;
12 | height: 100%;
13 | object-fit: cover;
14 | object-position: center;
15 | }
16 |
17 | .movie__back-link {
18 | position: absolute;
19 | z-index: 10;
20 | top: 30px;
21 | left: 30px;
22 | width: 44px;
23 | height: 44px;
24 | color: white;
25 | background-color: transparent;
26 | }
27 |
28 | .movie__back-link svg {
29 | width: 100%;
30 | height: 100%;
31 | }
32 |
33 | .movie__player {
34 | position: absolute;
35 | bottom: 0;
36 | left: 0;
37 | height: 200px;
38 | width: 100%;
39 | background: linear-gradient(to top, var(--colors-brown) 2%, #00000000);
40 | }
41 |
42 | .movie__player-title {
43 | position: absolute;
44 | transform: translate(-50%, -50%);
45 | left: 50%;
46 | top: 50%;
47 | font-weight: bold;
48 | font-size: calc(1rem * 26 / 16);
49 | color: white;
50 | }
51 |
52 | .movie__player-buttons {
53 | display: flex;
54 | align-items: center;
55 | position: absolute;
56 | top: 50%;
57 | left: 40px;
58 | transform: translateY(-50%);
59 | }
60 |
61 | .movie__player-button {
62 | width: 52px;
63 | height: 52px;
64 | margin-right: 40px;
65 | color: white;
66 | background-color: transparent;
67 | transition: all 200ms ease-out;
68 | }
69 |
70 | .movie__player-button:last-of-type {
71 | margin-right: 0;
72 | }
73 |
74 | .movie__player-button:hover {
75 | transform: scale(1.1);
76 | }
77 |
78 | .movie__player-button svg {
79 | width: 100%;
80 | height: 100%;
81 | }
82 |
83 | .movie__slider-wrapper {
84 | position: absolute;
85 | width: 96.5%;
86 | top: 0;
87 | left: 50%;
88 | transform: translateX(-50%);
89 | height: 5px;
90 | background-color: transparent;
91 | display: flex;
92 | }
93 |
94 | @keyframes increaseHeightOfWrapper {
95 | 30% {
96 | height: 6px;
97 | }
98 |
99 | 100% {
100 | height: 7px;
101 | }
102 | }
103 |
104 | .movie__slider-wrapper:hover {
105 | animation: increaseHeightOfWrapper 200ms forwards ease-out;
106 | cursor: pointer;
107 | }
108 |
109 | @keyframes increaseHeightOfThumb {
110 | 30% {
111 | height: 14px;
112 | width: 14px;
113 | bottom: 3px;
114 | }
115 |
116 | 100% {
117 | height: 16px;
118 | width: 16px;
119 | bottom: 1.5px;
120 | }
121 | }
122 |
123 | .movie__slider-wrapper:hover .movie__slider::-webkit-slider-thumb {
124 | height: 16px;
125 | width: 16px;
126 | animation: increaseHeightOfThumb 250ms forwards ease-out;
127 | }
128 |
129 | .movie__slider-progress {
130 | height: 100%;
131 | background-color: red;
132 | position: absolute;
133 | border-radius: 4px;
134 | width: 50%;
135 | }
136 |
137 | .movie__slider {
138 | -webkit-appearance: none;
139 | background: transparent;
140 | width: 100%;
141 | position: relative;
142 | }
143 |
144 | .movie__slider:focus {
145 | outline: none;
146 | }
147 |
148 | .movie__slider::-webkit-slider-runnable-track {
149 | width: 100%;
150 | height: 90%;
151 | cursor: pointer;
152 | background-color: gray;
153 | border-radius: 4px;
154 | border: 0.2px solid rgba(1, 1, 1, 0);
155 | }
156 |
157 | .movie__slider::-webkit-slider-thumb {
158 | height: 12px;
159 | width: 12px;
160 | border-radius: 50px;
161 | background-color: red;
162 | cursor: pointer;
163 | z-index: 10;
164 | box-shadow: 0 0px 5px rgba(224, 255, 255, 0.5);
165 | -webkit-appearance: none;
166 | position: relative;
167 | bottom: 4px;
168 | z-index: 200;
169 | }
170 |
171 | .movie__player-button-fullscreen {
172 | position: absolute;
173 | right: 35px;
174 | top: 50%;
175 | transform: translateY(-50%);
176 | }
177 |
178 | .movie__player-button-fullscreen:hover {
179 | transform: translateY(-50%) scale(1.1);
180 | }
181 |
--------------------------------------------------------------------------------
/src/components/Movie/Movie.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, Show } from 'solid-js'
2 | import { BackButton } from '../../icons/BackButton'
3 | import { Backward } from '../../icons/Backward'
4 | import { Forward } from '../../icons/Forward'
5 | import { FullScreen } from '../../icons/FullScreen'
6 | import { MinimizeScreen } from '../../icons/MinimizeScreen'
7 | import { Play } from '../../icons/Play'
8 | import { Stop } from '../../icons/Stop'
9 | import { VolumeEmpty } from '../../icons/VolumeEmpty'
10 | import { VolumeFull } from '../../icons/VolumeFull'
11 | import type { MovieWithMovieImage } from '../../lib/schemas'
12 | import './Movie.css'
13 |
14 | type MovieProps = {
15 | movie: MovieWithMovieImage
16 | }
17 |
18 | export function Movie(props: MovieProps) {
19 | const [isMoviePlaying, setIsMoviePlaying] = createSignal(true)
20 | const [isMovieMuted, setIsMovieMuted] = createSignal(false)
21 | const [isFullscreen, setIsFullscreen] = createSignal(false)
22 | const [currentProgress, setCurrentProgress] = createSignal(0)
23 | let videoElement: HTMLVideoElement | undefined = undefined
24 |
25 | function togglePauseState() {
26 | if (videoElement) {
27 | if (isMoviePlaying()) {
28 | videoElement.pause()
29 | setIsMoviePlaying(false)
30 | } else {
31 | videoElement.play()
32 | setIsMoviePlaying(true)
33 | }
34 | }
35 | }
36 |
37 | function toggleVolumeState() {
38 | if (videoElement) {
39 | if (isMovieMuted()) {
40 | videoElement.volume = 1
41 | setIsMovieMuted(false)
42 | } else {
43 | videoElement.volume = 0
44 | setIsMovieMuted(true)
45 | }
46 | }
47 | }
48 |
49 | function handleSkipBySeconds(secondsToBeSkipped: number) {
50 | if (videoElement) {
51 | const newTime = videoElement.currentTime + secondsToBeSkipped
52 | // Check to not go negative
53 | videoElement.currentTime = newTime < 0 ? 0 : newTime
54 | }
55 | }
56 |
57 | function handleProgressSliderChange(
58 | event: Event & { currentTarget: HTMLInputElement; target: Element }
59 | ) {
60 | const roundedProgress = Math.round(
61 | Number((event.target as HTMLInputElement).value)
62 | )
63 |
64 | const percentage = roundedProgress / 100
65 | if (videoElement) {
66 | const newTime = percentage * videoElement.duration
67 |
68 | videoElement.currentTime = newTime
69 | setCurrentProgress(roundedProgress)
70 | }
71 | }
72 |
73 | function toggleFullscreen() {
74 | if (isFullscreen()) {
75 | document.exitFullscreen()
76 | setIsFullscreen(false)
77 | } else {
78 | document.documentElement.requestFullscreen().catch(console.log)
79 | setIsFullscreen(true)
80 | }
81 | }
82 |
83 | return (
84 |
85 |
86 |
87 |
88 |
97 |
98 |
116 |
117 |
{props.movie.title}
118 |
119 |
128 |
129 |
136 |
137 |
144 |
145 |
154 |
155 |
164 |
165 |
166 | )
167 | }
168 |
--------------------------------------------------------------------------------
/src/components/MovieItem/MovieItem.css:
--------------------------------------------------------------------------------
1 | .movies__movie-graphic {
2 | height: 100%;
3 | width: 100%;
4 | object-fit: cover;
5 | object-position: center;
6 | border-radius: 5px;
7 | }
8 |
9 | .movies__movie-graphic img {
10 | height: 100%;
11 | width: 100%;
12 | object-fit: cover;
13 | object-position: center;
14 | }
15 |
16 | .movies__movie-graphic video {
17 | height: 100%;
18 | width: 100%;
19 | object-fit: cover;
20 | object-position: center;
21 | display: none;
22 | visibility: hidden;
23 | }
24 |
25 | .movies__movie-link-wrapper:hover .movies__movie-graphic video {
26 | display: block;
27 | visibility: visible;
28 | transition: all 200ms 250ms;
29 | }
30 |
31 | .movies__movie-link-wrapper:hover .movies__movie-graphic img {
32 | display: none;
33 | }
34 |
35 | .movies__movie-link-wrapper {
36 | position: relative;
37 | width: 395px;
38 | height: 220px;
39 | }
40 |
41 | .movies__movie-link {
42 | color: var(--colors-grey);
43 | text-decoration: none;
44 | width: 100%;
45 | height: 100%;
46 | position: absolute;
47 | }
48 |
49 | .movies__movie-link-wrapper:hover {
50 | z-index: 10;
51 | transform: scale(1.3);
52 | transition: 200ms ease-out 200ms;
53 | }
54 |
55 | .movies__movie-link-wrapper:hover .movies__movie-link {
56 | display: flex;
57 | align-items: center;
58 | flex-direction: column;
59 | transition: 200ms ease-out 200ms;
60 | box-shadow: 0 5px 20px black;
61 | }
62 |
63 | .movies__movie-link-wrapper:hover .movies__movie-graphic {
64 | border-bottom-left-radius: 0;
65 | border-bottom-right-radius: 0;
66 | }
67 |
68 | .movies__movie-info {
69 | visibility: hidden;
70 | width: 100%;
71 | height: 0;
72 | display: flex;
73 | flex-direction: column;
74 | background-color: var(--colors-brown);
75 | text-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
76 | border-bottom-left-radius: 3px;
77 | border-bottom-right-radius: 3px;
78 | box-shadow: 0 1px 10px #000000a3;
79 | padding: 7px 10px 15px 10px;
80 | }
81 |
82 | .movies__movie-link-wrapper:hover .movies__movie-info {
83 | visibility: visible;
84 | height: auto;
85 | transition: all 200ms 200ms;
86 | }
87 |
88 | .movies__movie-info-heading {
89 | font-size: calc(1rem * 16 / 16);
90 | margin-top: 5px;
91 | font-weight: 700;
92 | }
93 |
94 | .movies__movie-info-description {
95 | font-size: calc(1rem * 14 / 16);
96 | font-weight: 500;
97 | margin-top: 15px;
98 | }
99 |
100 | .movies__movie-info-length {
101 | font-size: calc(1rem * 12 / 16);
102 | color: var(--colors-brown);
103 | background-color: var(--colors-grey);
104 | padding: 2px 4px;
105 | width: max-content;
106 | text-shadow: none;
107 | margin-top: 10px;
108 | border-radius: 2px;
109 | box-shadow: 0 1px 5px rgba(0, 0, 0, 0.274);
110 | }
111 |
112 | .movies__movie-graphic-placeholder {
113 | background-color: var(--colors-brown);
114 | box-shadow: 0 1px 2px black;
115 | height: 100%;
116 | width: 100%;
117 | position: relative;
118 | overflow: hidden;
119 | }
120 |
121 | @keyframes loading {
122 | from {
123 | left: -30%;
124 | }
125 |
126 | to {
127 | left: 100%;
128 | }
129 | }
130 |
131 | .movies__movie-graphic-placeholder div {
132 | position: absolute;
133 | top: 0;
134 | left: -30%;
135 | width: 30%;
136 | height: 100%;
137 | background: radial-gradient(#ffffff1f, #14141485);
138 | animation: loading 400ms infinite ease-out;
139 | }
140 |
--------------------------------------------------------------------------------
/src/components/MovieItem/MovieItem.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal } from 'solid-js'
2 | import type { MovieWithTrailerImage } from '../../lib/schemas'
3 | import './MovieItem.css'
4 |
5 | type MovieItemProps = {
6 | movie: MovieWithTrailerImage
7 | }
8 |
9 | export function MovieItem(props: MovieItemProps) {
10 | const [hasImageLoaded, setHasImageLoaded] = createSignal(false)
11 |
12 | function handleOnLoad() {
13 | setHasImageLoaded(true)
14 | }
15 |
16 | return (
17 |
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/Movies/Movies.css:
--------------------------------------------------------------------------------
1 | .movies__section {
2 | display: flex;
3 | flex-direction: column;
4 | position: relative;
5 | z-index: 50;
6 | bottom: 150px;
7 | overflow: visible;
8 | }
9 |
10 | .movies__heading {
11 | font-weight: 700;
12 | color: var(--colors-grey);
13 | font-size: calc(1rem * 35 / 16);
14 | }
15 |
16 | .movies__wrapper {
17 | display: flex;
18 | align-items: center;
19 | margin-top: 30px;
20 | justify-content: center;
21 | column-gap: 20px;
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/Movies/Movies.tsx:
--------------------------------------------------------------------------------
1 | import { For } from 'solid-js'
2 | import './Movies.css'
3 | import type { MovieWithTrailerImage } from '../../lib/schemas'
4 | import { MovieItem } from '../MovieItem/MovieItem'
5 |
6 | type MoviesProps = {
7 | movies: Array
8 | }
9 |
10 | export function Movies(props: MoviesProps) {
11 | return (
12 |
13 | Movies
14 |
15 | {(movie) => }
16 |
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/Trailer/Trailer.css:
--------------------------------------------------------------------------------
1 | .trailer__wrapper {
2 | width: 100%;
3 | height: 100%;
4 | position: relative;
5 | background-color: transparent;
6 | }
7 |
8 | .trailer__graphic {
9 | width: 100%;
10 | height: 100%;
11 | object-fit: cover;
12 | object-position: center;
13 | position: absolute;
14 | top: 0;
15 | left: 0;
16 | }
17 |
18 | .trailer__info-wrapper {
19 | position: absolute;
20 | z-index: 10;
21 | display: flex;
22 | align-items: flex-start;
23 | flex-direction: column;
24 | top: 35%;
25 | left: 70px;
26 | width: 1200px;
27 | }
28 |
29 | .trailer__info-title {
30 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.45);
31 | color: var(--colors-white);
32 | font-weight: 600;
33 | font-size: calc(1rem * 54 / 16);
34 | }
35 |
36 | .trailer__info-description {
37 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.45);
38 | color: var(--colors-white);
39 | font-weight: 500;
40 | margin-top: 28px;
41 | font-size: calc(1rem * 26 / 16);
42 | width: 75ch;
43 | line-height: 1.3;
44 | }
45 |
46 | .trailer__info-link {
47 | color: var(--colors-black);
48 | background-color: var(--colors-white);
49 | padding: 10px 20px;
50 | border-radius: 4px;
51 | margin-top: 80px;
52 | display: flex;
53 | align-items: center;
54 | font-size: calc(1rem * 28 / 16);
55 | font-weight: 600;
56 | text-decoration: none;
57 | transition: all 150ms;
58 | }
59 |
60 | .trailer__info-link:hover {
61 | background-color: var(--colors-grey);
62 | }
63 |
64 | .trailer__info-link svg {
65 | width: 26px;
66 | height: 26px;
67 | margin-right: 15px;
68 | }
69 |
70 | .trailer__overlay {
71 | z-index: 5;
72 | position: absolute;
73 | left: 0;
74 | top: 0;
75 | height: 100%;
76 | width: 100%;
77 | background-image: linear-gradient(
78 | to top,
79 | var(--colors-brown) 5%,
80 | rgba(0, 0, 0, 0.2)
81 | );
82 | }
83 |
84 | .trailer__tag {
85 | position: absolute;
86 | top: 60%;
87 | right: 0;
88 | display: flex;
89 | z-index: 10;
90 | }
91 |
92 | .trailer__tag-button {
93 | width: 50px;
94 | height: 50px;
95 | color: var(--colors-white);
96 | border-radius: 100%;
97 | border: 1px solid var(--colors-white);
98 | display: flex;
99 | align-items: center;
100 | justify-content: center;
101 | background-color: #5e5e5e69;
102 | margin-right: 25px;
103 | transition: all 150ms;
104 | }
105 |
106 | .trailer__tag-button:hover {
107 | background-color: rgba(0, 0, 0, 0.247);
108 | }
109 |
110 | .trailer__tag-button svg {
111 | width: 24px;
112 | height: 24px;
113 | }
114 |
115 | .trailer__tag-age {
116 | width: 150px;
117 | background-color: rgba(0, 0, 0, 0.2);
118 | display: flex;
119 | align-items: center;
120 |
121 | height: 60px;
122 | border-left: 2px solid var(--colors-grey);
123 | }
124 |
125 | .trailer__tag-age-text {
126 | margin-left: 25px;
127 | color: var(--colors-white);
128 | font-weight: 500;
129 | font-size: calc(1rem * 28 / 16);
130 | }
131 |
--------------------------------------------------------------------------------
/src/components/Trailer/Trailer.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, Show } from 'solid-js'
2 | import { MuteTrailer } from '../../icons/MuteTrailer'
3 | import { Play } from '../../icons/Play'
4 | import { Replay } from '../../icons/Replay'
5 | import { SoundTrailer } from '../../icons/SoundTrailer'
6 | import type { MovieWithTrailerImage } from '../../lib/schemas'
7 | import './Trailer.css'
8 |
9 | type TrailerProps = {
10 | movie: MovieWithTrailerImage
11 | }
12 |
13 | export function Trailer(props: TrailerProps) {
14 | const [shouldShowImage, setShouldShowImage] = createSignal(false)
15 | const [videoVolume, setVideoVolume] = createSignal(1)
16 |
17 | let trailerVideoElement: HTMLVideoElement | undefined
18 |
19 | function handleTrailerEnd() {
20 | setShouldShowImage(true)
21 | }
22 |
23 | function handleVideoVolumeChange() {
24 | if (trailerVideoElement) {
25 | const isTrailerMuted = trailerVideoElement?.volume === 0
26 | if (isTrailerMuted) {
27 | trailerVideoElement.volume = 1
28 | setVideoVolume(1)
29 | } else {
30 | trailerVideoElement.volume = 0
31 | setVideoVolume(0)
32 | }
33 | }
34 | }
35 |
36 | function onReplay() {
37 | if (trailerVideoElement) {
38 | setShouldShowImage(false)
39 | setVideoVolume(1)
40 | trailerVideoElement.currentTime = 0
41 | }
42 | }
43 |
44 | return (
45 |
46 |
58 | }
59 | >
60 |

65 |
66 |
67 |
75 |
76 |
77 |
78 |
85 |
86 |
87 |
88 |
100 |
101 |
102 |
103 |
104 | 12
105 |
106 |
107 |
108 | )
109 | }
110 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './MovieItem/MovieItem'
2 | export * from './Movies/Movies'
3 | export * from './navigation/Navigation'
4 | export * from './Trailer/Trailer'
5 | export * from './Movie/Movie'
6 |
--------------------------------------------------------------------------------
/src/components/navigation/Navigation.css:
--------------------------------------------------------------------------------
1 | .navigation__wrapper {
2 | position: absolute;
3 | top: 55px;
4 | left: 70px;
5 | z-index: 10;
6 | }
7 |
8 | .navigation__link {
9 | text-decoration: none;
10 | }
11 |
12 | .navigation__art {
13 | width: 220px;
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/navigation/Navigation.tsx:
--------------------------------------------------------------------------------
1 | import { NarutoMainArt } from '../../arts/NarutoMainArt'
2 | import './Navigation.css'
3 |
4 | export function Navigation() {
5 | return (
6 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/src/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | interface ImportMetaEnv {
3 | readonly TOKEN: string
4 | readonly ENDPOINT: string
5 | }
6 |
7 | interface ImportMeta {
8 | readonly env: ImportMetaEnv
9 | }
10 |
--------------------------------------------------------------------------------
/src/icons/BackButton.tsx:
--------------------------------------------------------------------------------
1 | import type { ClassProps } from '../lib/types'
2 |
3 | export function BackButton(props: ClassProps) {
4 | return (
5 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/src/icons/Backward.tsx:
--------------------------------------------------------------------------------
1 | export function Backward() {
2 | return (
3 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/icons/Forward.tsx:
--------------------------------------------------------------------------------
1 | export function Forward() {
2 | return (
3 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/icons/FullScreen.tsx:
--------------------------------------------------------------------------------
1 | export function FullScreen() {
2 | return (
3 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/icons/MinimizeScreen.tsx:
--------------------------------------------------------------------------------
1 | export function MinimizeScreen() {
2 | return (
3 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/icons/MuteTrailer.tsx:
--------------------------------------------------------------------------------
1 | import type { ClassProps } from '../lib/types'
2 |
3 | export function MuteTrailer(props: ClassProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/icons/Play.tsx:
--------------------------------------------------------------------------------
1 | import type { ClassProps } from '../lib/types'
2 |
3 | export function Play(props: ClassProps) {
4 | return (
5 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/src/icons/Replay.tsx:
--------------------------------------------------------------------------------
1 | import type { ClassProps } from '../lib/types'
2 |
3 | export function Replay(props: ClassProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/icons/SoundTrailer.tsx:
--------------------------------------------------------------------------------
1 | import type { ClassProps } from '../lib/types'
2 |
3 | export function SoundTrailer(props: ClassProps) {
4 | return (
5 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/src/icons/Stop.tsx:
--------------------------------------------------------------------------------
1 | export function Stop() {
2 | return (
3 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/icons/VolumeEmpty.tsx:
--------------------------------------------------------------------------------
1 | export function VolumeEmpty() {
2 | return (
3 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/icons/VolumeFull.tsx:
--------------------------------------------------------------------------------
1 | export function VolumeFull() {
2 | return (
3 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/layouts/MySiteLayout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import '@fontsource/montserrat'
3 | ---
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
23 |
24 |
25 |
26 |
27 |
31 |
32 |
33 |
34 |
35 | Kiba
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const movieIds = {
2 | lastTower: 'lost-tower',
3 | bloodPrison: 'blood-prison',
4 | borutoGenerations: 'boruto-generations',
5 | roadNinja: 'road-ninja',
6 | theLast: 'the-last',
7 | } as const
8 |
--------------------------------------------------------------------------------
/src/lib/schemas.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const MovieSchema = z.object({
4 | id: z.string(),
5 | title: z.string(),
6 | description: z.string(),
7 | length: z.string(),
8 | trailer: z.string(),
9 | movie: z.string(),
10 | image: z.string(),
11 | })
12 |
13 | export type Movie = z.infer
14 |
15 | export const MovieWithImageSchema = MovieSchema.extend({
16 | imageUrl: z.string(),
17 | })
18 |
19 | export type MovieWithImage = z.infer
20 |
21 | export const MovieWithMovieImageSchema = MovieWithImageSchema.extend({
22 | movieUrl: z.string(),
23 | })
24 |
25 | export type MovieWithMovieImage = z.infer
26 |
27 | export const MovieWithTrailerImageSchema = MovieWithImageSchema.extend({
28 | trailerUrl: z.string(),
29 | })
30 |
31 | export type MovieWithTrailerImage = z.infer
32 |
33 | export const DataSchema = z.array(MovieSchema)
34 |
35 | export type Data = z.infer
36 |
37 | export const ResponseSchema = z.object({
38 | data: DataSchema,
39 | })
40 |
41 | export type Response = z.infer
42 |
43 | export const SingleResponseSchema = z.object({
44 | data: MovieSchema,
45 | })
46 |
47 | export type SingleResponse = z.infer
--------------------------------------------------------------------------------
/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | export type ClassProps = {
2 | class?: string
3 | }
4 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuid } from 'uuid'
2 |
3 | const pluralRule = new Intl.PluralRules('en-US', {
4 | type: 'ordinal',
5 | })
6 |
7 | const suffixes = new Map([
8 | ['one', 'st'],
9 | ['two', 'nd'],
10 | ['few', 'rd'],
11 | ['other', 'th'],
12 | ])
13 |
14 | function formatOrdinals(number: number) {
15 | const rule = pluralRule.select(number)
16 | const suffix = suffixes.get(rule) as Suffix
17 | return `${number}${suffix}`
18 | }
19 |
20 | type Suffix = 'th' | 'rd' | 'nd' | 'st'
21 |
22 | type Key = `${number}${Suffix}`
23 |
24 | export function generateUniqueIds(total: number) {
25 | const uniqueIds = {} as Record
26 |
27 | const autoGeneratedArrayWithNumbers = [...Array(total).keys()].map(
28 | (number) => number + 1
29 | )
30 |
31 | autoGeneratedArrayWithNumbers.forEach((number) => {
32 | uniqueIds[formatOrdinals(number) as Key] = uuid()
33 | })
34 |
35 | return uniqueIds
36 | }
37 |
--------------------------------------------------------------------------------
/src/pages/[id].astro:
--------------------------------------------------------------------------------
1 | ---
2 | import MySiteLayout from '../layouts/MySiteLayout.astro'
3 | import '../styles/movie.css'
4 | import { Movie } from '../components'
5 | import { z } from 'zod'
6 | import { MovieWithMovieImage, SingleResponseSchema } from '../lib/schemas'
7 |
8 | function getAssetURL(assetId: string) {
9 | return `${ENDPOINT}/assets/${assetId}?access_token=${TOKEN}`
10 | }
11 |
12 | const ENDPOINT = import.meta.env.ENDPOINT
13 | const TOKEN = import.meta.env.TOKEN
14 |
15 | const { id } = Astro.params
16 |
17 | const response = await fetch(
18 | `${ENDPOINT}/items/movies/${z.string().parse(id)}?access_token=${TOKEN}`
19 | )
20 |
21 | const data = SingleResponseSchema.parse(await response.json()).data
22 |
23 | const movie: MovieWithMovieImage = {
24 | ...data,
25 | imageUrl: getAssetURL(data.image),
26 | movieUrl: getAssetURL(data.movie),
27 | }
28 | ---
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/pages/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import MySiteLayout from '../layouts/MySiteLayout.astro'
3 | import '../styles/trailer.css'
4 | import { Trailer, Navigation, Movies } from '../components'
5 | import { MovieWithTrailerImageSchema, ResponseSchema } from '../lib/schemas'
6 |
7 | function getAssetURL(assetId: string) {
8 | return `${ENDPOINT}/assets/${assetId}?access_token=${TOKEN}`
9 | }
10 |
11 | const ENDPOINT = import.meta.env.ENDPOINT
12 | const TOKEN = import.meta.env.TOKEN
13 |
14 | const response = await fetch(`${ENDPOINT}/items/movies?access_token=${TOKEN}`)
15 | const data = ResponseSchema.parse(await response.json()).data
16 |
17 | const movies = data.map((movie) => {
18 | return MovieWithTrailerImageSchema.parse({
19 | ...movie,
20 | trailerUrl: getAssetURL(movie.trailer),
21 | imageUrl: getAssetURL(movie.image),
22 | })
23 | })
24 | ---
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/styles/movie.css:
--------------------------------------------------------------------------------
1 | body {
2 | height: 100%;
3 | overflow: auto;
4 | }
5 |
--------------------------------------------------------------------------------
/src/styles/trailer.css:
--------------------------------------------------------------------------------
1 | body {
2 | /* Here we add another 100px to the full height since we only have 5 movies on this site, and hovering over them, they will have to scale and it is a bit tricky getting space beneath them, causing them to sit on the edge of the bottom. */
3 | height: calc(100% + 100px);
4 | overflow-y: scroll;
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | // Enable top-level await, and other modern ESM features.
4 | "target": "ESNext",
5 | "module": "ESNext",
6 | // Enable node-style module resolution, for things like npm package imports.
7 | "moduleResolution": "node",
8 | // Enable JSON imports.
9 | "resolveJsonModule": true,
10 | // Enable stricter transpilation for better output.
11 | "isolatedModules": true,
12 | // Astro will directly run your TypeScript code, no transpilation needed.
13 | "noEmit": true,
14 | // Enable strict type checking.
15 | "strict": true,
16 | // Error when a value import is only used as a type.
17 | "importsNotUsedAsValues": "error",
18 | "noUncheckedIndexedAccess": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "jsxImportSource": "solid-js",
22 | "jsx": "preserve"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------