├── .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 |
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 |