Compare commits
	
		
			301 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 4a128044bf | |||
| dec15194e4 | |||
| 2fed03a882 | |||
| 74d5868a5c | |||
| 609ebc3940 | |||
| 5d2e667ceb | |||
| 5786f55e78 | |||
| ba888fb303 | |||
| 5f942848aa | |||
| 3a58e77da0 | |||
| 8d03ea5eec | |||
| 2f4c6e2543 | |||
| 7829ad7298 | |||
| 6642b2531e | |||
| abf005fd8d | |||
| c6cff7a0c4 | |||
| 5f1de791c0 | |||
| 91c75198de | |||
| 3b98faeddd | |||
| cbf400c118 | |||
| c49f0816c8 | |||
| 9cf2bb9bd8 | |||
| 6ceb7861de | |||
| 97ed8a491e | |||
| cf0bd9aa84 | |||
| a62de038a4 | |||
| eeac27370b | |||
| 88edc03b8b | |||
| 4a5dddac75 | |||
| fee26fb9e1 | |||
| 9ee9eff8a3 | |||
| fda353f746 | |||
| 762eb6fe79 | |||
| 335155eb8f | |||
| 577a64a32f | |||
| b7ac8bce83 | |||
| 3594b18872 | |||
| 29dfe55974 | |||
| 3111513458 | |||
| 67686095a5 | |||
| e7a0e08938 | |||
| 41067aae84 | |||
| f7fe582200 | |||
| 09a25e0f37 | |||
| d061ca06e2 | |||
| 2b8d9868b9 | |||
| fe86bbae40 | |||
| 81bead113f | |||
| 0015588f9c | |||
| 132dd2803e | |||
| f8196b162e | |||
| fde8fd9259 | |||
| dc69b4086c | |||
| 2a893f5871 | |||
| 96c412ca49 | |||
| d279298dec | |||
| d13d883db9 | |||
| b7e7fe9c55 | |||
| d12dfc3c8e | |||
| 890d0c428d | |||
| 105fb02378 | |||
| 7478016384 | |||
| 8216502eeb | |||
| 5eadb0b47a | |||
| a4a669e774 | |||
| 8308a7231a | |||
| d585af2193 | |||
| fe162eb081 | |||
| ae3b228cf5 | |||
| 023b2cd86e | |||
| 1f51cead5c | |||
| be29242cd3 | |||
| 742caad102 | |||
| eae632d0da | |||
| 7f68d2bf79 | |||
| df0432a99f | |||
| 92a9ccd470 | |||
| f5ff2ba44c | |||
| 6c80fdff86 | |||
| f425055a53 | |||
| 1ddaf25150 | |||
| 5cacbec11a | |||
| 728e3a5406 | |||
| 8a75fd7c22 | |||
| baf16f2a55 | |||
| a9c06a6aaf | |||
| 9bb5211b4e | |||
| 8c28f7d5f3 | |||
| d658d90d18 | |||
| 19366f29a9 | |||
| 3628b2bfaa | |||
| 0085daec61 | |||
| e45dffcfbe | |||
| 7e24829300 | |||
| b3266af6bc | |||
| 79893c4652 | |||
| 23a1fe5f7f | |||
| ca873f14c7 | |||
| 80ce96d6b2 | |||
| 333314fa69 | |||
| 7c969b55dc | |||
| eb253609d5 | |||
| 06a48e738d | |||
| 15b6206b05 | |||
| 2c3de34b22 | |||
| 1bfdb8629f | |||
| afa3c21c99 | |||
| 1161a25c97 | |||
| 7dd2d3ee82 | |||
| 1d2e88749c | |||
| d39e02cb56 | |||
| 21ff5f22a7 | |||
| fca123e26d | |||
| b5c56a62de | |||
| dc98f9ced2 | |||
| 394cd71e44 | |||
| b7db3fec62 | |||
| 80a65f1940 | |||
| a6dbb2ba59 | |||
| 4dd51dc4cd | |||
| caa8dffc87 | |||
| 25dd8bea9e | |||
| da99616086 | |||
| 28950a974c | |||
| 8f454b54d8 | |||
| 982d8c353c | |||
| 03bbb5781a | |||
| 0433f8c910 | |||
| f21d879af0 | |||
| c180bdf98a | |||
| bf44668a12 | |||
| acc9bda292 | |||
| bb834e7c2e | |||
| 2d58cca30d | |||
| a5a4bd2641 | |||
| 38813229c9 | |||
| d58504cde3 | |||
| 04c9e019d3 | |||
| d24a318de8 | |||
| 3b0039b51b | |||
| 5dd3509466 | |||
| dbde8bc00b | |||
| 7449650b64 | |||
| a0810fbee1 | |||
| a614974a35 | |||
| b24b091a3e | |||
| 3aefb4c4ac | |||
| 2e2ca59334 | |||
| 6463d5ef4c | |||
| 4432d8e604 | |||
| 7ded50ea84 | |||
| d49285f1e2 | |||
| b9f39e690d | |||
| fc2b139653 | |||
| 3ceb2d7a6f | |||
| 95ad74a1b5 | |||
| ca4d87b315 | |||
| 86efb04eb8 | |||
| 67de2a91fe | |||
| df388b929a | |||
| 9083b0a5d0 | |||
| dbc225a41c | |||
| 18a0acfe19 | |||
| 4488e53ff2 | |||
| 7bced50952 | |||
| 824a2143ef | |||
| 5c1b9a00f4 | |||
| aaef8a6107 | |||
| 9f3745b71c | |||
| 5431b5be40 | |||
| acfa3e9d54 | |||
| 3c72bdf3c2 | |||
| dc2359ff6a | |||
| 0b6398cc4c | |||
| d3a3160cf8 | |||
| b021882013 | |||
| d1cbbfffd8 | |||
| 5104df0af0 | |||
| 5e330861ca | |||
| 4d27fdb25a | |||
| 2ab1609bd9 | |||
| aa7e6a2a53 | |||
| 2937e7b974 | |||
| 2371907f54 | |||
| 6615827b29 | |||
| 97c23fa895 | |||
| 39930428a9 | |||
| 83b14e0744 | |||
| f180b7f39b | |||
| a2fbfcb13c | |||
| d640f7f882 | |||
| d43c12b103 | |||
| 38c3792675 | |||
| ac2785abd5 | |||
| 1ff6a0e831 | |||
| 7a3b709404 | |||
|  | d63cb4ac52 | ||
| b6ee1cf906 | |||
| 60201b1b67 | |||
| a8b8603649 | |||
| e193528fe9 | |||
| 73afb34964 | |||
| 65bbc453e6 | |||
|  | 188477ab64 | ||
|  | a31bfb6b39 | ||
| 681ed69ef0 | |||
| b771428b4d | |||
| fc0103ee5d | |||
| 55067b81b8 | |||
| dfe2b5df09 | |||
| dc0c435163 | |||
| 9d1ac56b9a | |||
| fc2c3664d9 | |||
| 0bd45ed777 | |||
| 3912766982 | |||
| 3becce2a6c | |||
| 20b8692c91 | |||
| 14ac780aa5 | |||
| d836870612 | |||
| bc6f706e4a | |||
| 6ac6a9b039 | |||
| 85be80d712 | |||
| 105be1e411 | |||
| 010830243e | |||
| 923dc46dc7 | |||
| f2ef5366f5 | |||
| 20380a4587 | |||
| 069ef2c458 | |||
| 2f430b2d8f | |||
| f7a579a438 | |||
| b9ddd998bc | |||
| ae59d02df2 | |||
| ec205bab0c | |||
| ed49d825b8 | |||
| a9db8be46a | |||
| 1caa3c7fae | |||
| 2ea4bffd49 | |||
| 5ae52f59fc | |||
| a7e6d25d3f | |||
| 83751a4e3e | |||
| 0e9daab187 | |||
| 4390491873 | |||
| d620a4cc2e | |||
| 32669e5bef | |||
| 6edad3991f | |||
| 50acf0bedc | |||
| d4369ec7a4 | |||
| c16543099e | |||
| f2a65d755c | |||
| 1fd48edd42 | |||
| 68e45303c6 | |||
| 532993e9dd | |||
| d19d72ce0c | |||
| d1820a08cf | |||
| bc73665b12 | |||
| 9edb19569a | |||
| 7802a89d15 | |||
| 915260f41b | |||
| 0d57e9a03b | |||
| 582207d453 | |||
| b1b08bfa04 | |||
| 14e883672d | |||
| 7a405140db | |||
| 35497f5bd2 | |||
| 91b19785d6 | |||
| a301d21cc2 | |||
| a2a4b9a553 | |||
| 45f45559fd | |||
| 458256132a | |||
| 0f2c166e1c | |||
| 1c7a688cb8 | |||
| 6269f178e9 | |||
| 3e7527ee19 | |||
| 2236316863 | |||
| cc2fded193 | |||
| f32e0a8ab0 | |||
| ec6e6d2ba0 | |||
| ca85635b03 | |||
| 32257dc64e | |||
| 6bba319735 | |||
| dcce972fdc | |||
| 32e25fb983 | |||
| e7882869e6 | |||
| d0a251f69a | |||
| 9bc7f29162 | |||
| 3ff963f007 | |||
| bcfce66ec0 | |||
| 33e3ee3489 | |||
| e3502a7690 | |||
| 8d09ba4d07 | |||
| ba670d06aa | |||
| a11ad2f651 | |||
| 755bd116d5 | |||
| 9e33784781 | |||
| 470bcdd72e | |||
| d56a7d4dfe | |||
| b46e586c92 | |||
| 563eb3f1ef | |||
| 98644513ad | |||
| 3033db02b8 | |||
| 70a6ed189b | 
							
								
								
									
										107
									
								
								.drone.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,107 @@ | ||||
| --- | ||||
| kind: pipeline | ||||
| type: docker | ||||
| name: seasoned build | ||||
|  | ||||
| platform: | ||||
|   os: linux | ||||
|   arch: amd64 | ||||
|  | ||||
| volumes: | ||||
|   - name: cache | ||||
|     host: | ||||
|       path: /tmp/cache | ||||
|  | ||||
| steps: | ||||
|   - name: Load cached frontend packages | ||||
|     image: sinlead/drone-cache:1.0.0 | ||||
|     settings: | ||||
|       action: load | ||||
|       key: yarn.lock | ||||
|       mount: node_modules | ||||
|       prefix: yarn-modules-seasoned | ||||
|     volumes: | ||||
|       - name: cache | ||||
|         path: /cache | ||||
|  | ||||
|   - name: Frontend install | ||||
|     image: node:18.2.0 | ||||
|     commands: | ||||
|       - node -v | ||||
|       - yarn --version | ||||
|       - yarn | ||||
|  | ||||
|   - name: Cache frontend packages | ||||
|     image: sinlead/drone-cache:1.0.0 | ||||
|     settings: | ||||
|       action: save | ||||
|       key: yarn.lock | ||||
|       mount: node_modules | ||||
|       prefix: yarn-modules-seasoned | ||||
|     volumes: | ||||
|       - name: cache | ||||
|         path: /cache | ||||
|  | ||||
|   - name: Frontend build | ||||
|     image: node:18.2.0 | ||||
|     commands: | ||||
|       - yarn build | ||||
|     environment: | ||||
|       ELASTIC: | ||||
|         from_secret: ELASTIC | ||||
|       ELASTIC_INDEX: | ||||
|         from_secret: ELASTIC_INDEX | ||||
|       SEASONED_API: | ||||
|         from_secret: SEASONED_API | ||||
|       SEASONED_DOMAIN: | ||||
|         from_secret: SEASONED_DOMAIN | ||||
|  | ||||
|   - name: Lint project using eslint | ||||
|     image: node:18.2.0 | ||||
|     commands: | ||||
|       - yarn lint | ||||
|     failure: ignore | ||||
|  | ||||
|   - name: Build and publish docker image | ||||
|     image: plugins/docker | ||||
|     settings: | ||||
|       registry: ghcr.io | ||||
|       repo: ghcr.io/kevinmidboe/seasoned | ||||
|       dockerfile: Dockerfile | ||||
|       username: | ||||
|         from_secret: GITHUB_USERNAME | ||||
|       password: | ||||
|         from_secret: GITHUB_PASSWORD | ||||
|       tags: latest | ||||
|     when: | ||||
|       event: | ||||
|         - push | ||||
|       branch: | ||||
|         - master | ||||
|  | ||||
|   - name: deploy | ||||
|     image: appleboy/drone-ssh | ||||
|     pull: true | ||||
|     secrets: | ||||
|       - ssh_key | ||||
|     when: | ||||
|       event: | ||||
|         - push | ||||
|       branch: | ||||
|         - master | ||||
|         - drone-test | ||||
|       status: success | ||||
|     settings: | ||||
|       host: 10.0.0.54 | ||||
|       username: root | ||||
|       key: | ||||
|         from_secret: ssh_key | ||||
|       command_timeout: 600s | ||||
|       script: | ||||
|         - /home/kevin/deploy/seasoned.sh | ||||
|  | ||||
| trigger: | ||||
|   event: | ||||
|     include: | ||||
|       - push | ||||
|       # - pull_request | ||||
							
								
								
									
										4
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,4 @@ | ||||
| SEASONED_API= | ||||
| ELASTIC= | ||||
| ELASTIC_INDEX=shows,movies | ||||
| SEASONED_DOMAIN= | ||||
							
								
								
									
										31
									
								
								.eslintrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,31 @@ | ||||
| { | ||||
|   "root": true, | ||||
|   "parser": "vue-eslint-parser", | ||||
|   "parserOptions": { | ||||
|     "parser": "@typescript-eslint/parser", | ||||
|     "sourceType": "module" | ||||
|   }, | ||||
|   "plugins": [ | ||||
|     "@typescript-eslint" | ||||
|   ], | ||||
|   "extends": [ | ||||
|     "@vue/eslint-config-airbnb", | ||||
|     "plugin:vue/recommended", | ||||
|     "plugin:@typescript-eslint/recommended", | ||||
|     "plugin:prettier/recommended", | ||||
|   ], | ||||
|   "rules": { | ||||
|     "vue/no-v-model-argument": "off", | ||||
|     "no-underscore-dangle": "off", | ||||
|     "vue/multi-word-component-names": "off", | ||||
|     "no-shadow": "off", | ||||
|     "@typescript-eslint/no-shadow": ["error"], | ||||
|   }, | ||||
|   "settings": { | ||||
|     "import/resolver": { | ||||
|       webpack: { | ||||
|         config: "./webpack.config.js" | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -1,5 +1,6 @@ | ||||
| # config file - copy config.json.example | ||||
| src/config.json | ||||
| .env | ||||
|  | ||||
| # Build directory | ||||
| dist/ | ||||
|   | ||||
| @@ -1,8 +0,0 @@ | ||||
| <IfModule mod_rewrite.c> | ||||
|   RewriteEngine On | ||||
|   RewriteBase / | ||||
|   RewriteRule ^index\.html$ - [L] | ||||
|   RewriteCond %{REQUEST_FILENAME} !-f | ||||
|   RewriteCond %{REQUEST_FILENAME} !-d | ||||
|   RewriteRule . /index.html [L] | ||||
| </IfModule> | ||||
							
								
								
									
										10
									
								
								.prettierrc
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,10 @@ | ||||
| { | ||||
|   "tabWidth": 2, | ||||
|   "useTabs": false, | ||||
|   "semi": true, | ||||
|   "singleQuote": false, | ||||
|   "bracketSpacing": true, | ||||
|   "arrowParens": "avoid", | ||||
|   "vueIndentScriptAndStyle": true, | ||||
|   "trailingComma": "none" | ||||
| } | ||||
							
								
								
									
										11
									
								
								Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,11 @@ | ||||
| FROM nginx:1.23.1 | ||||
|  | ||||
| COPY public /usr/share/nginx/html | ||||
| COPY nginx.conf /etc/nginx/conf.d/default.conf.template | ||||
| COPY docker-entrypoint.sh /docker-entrypoint.d/05-docker-entrypoint.sh | ||||
|  | ||||
| RUN chmod +x /docker-entrypoint.d/05-docker-entrypoint.sh | ||||
|  | ||||
| EXPOSE 5000 | ||||
|  | ||||
| LABEL org.opencontainers.image.source https://github.com/kevinmidboe/seasoned | ||||
							
								
								
									
										75
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,45 +1,72 @@ | ||||
| # The Movie Database App | ||||
| # Seasoned Request | ||||
|  | ||||
| A Vue.js project. | ||||
| Seasoned request is frontend vue application for searching, requesting and viewing account watch activity. | ||||
|  | ||||
|  | ||||
|  | ||||
| ## Demo | ||||
|  | ||||
| [TMDB Vue App](https://tmdb-vue-app.herokuapp.com/) | ||||
|  | ||||
| ## Config setup | ||||
| Set seasonedShows api endpoint and/or elastic.    | ||||
|  - SeasonedShows [can be found here](https://github.com/kevinmidboe/seasonedshows) and is the matching backend to fetch tmdb search results, tmdb lists, request new content, check plex status and lets owner search and add torrents to download. | ||||
|  - Elastic is optional and can be used for a instant search feature for all movies and shows registered in tmdb. | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "SEASONED_URL": "http://localhost:31459/api", | ||||
|   "ELASTIC_URL": "http://localhost:9200" | ||||
| } | ||||
| ```bash | ||||
| # make copy of example environment file | ||||
| cp .env.example .env | ||||
| ``` | ||||
| *Set ELASTIC_URL to undefined or false to disable* | ||||
|  | ||||
| ## Build Setup | ||||
| ```bash | ||||
| # .env sane default values | ||||
| SEASONED_API= | ||||
| ELASTIC= | ||||
| ELASTIC_INDEX=shows,movies | ||||
| SEASONED_DOMAIN= | ||||
| ``` | ||||
|  | ||||
| ``` bash | ||||
| - Leave SEASONED_API empty to request `/api` from same origin and proxy passed by nginx, set if hosting [seasonedShows backend api](https://github.com/KevinMidboe/seasonedShows) locally. | ||||
| - Elastic is optional and can be used for a instant search feature for all movies and shows registered in tmdb, leave empty to disable. | ||||
|  | ||||
| ```bash | ||||
| # .env example values | ||||
| SEASONED_API=http://localhost:31459 | ||||
| ELASTIC=http://localhost:9200 | ||||
| ELASTIC_INDEX=shows,movies | ||||
| SEASONED_DOMAIN=request.movie | ||||
| ``` | ||||
|  | ||||
| ## Build Steps | ||||
|  | ||||
| ```bash | ||||
| # install dependencies | ||||
| npm install | ||||
| yarn | ||||
|  | ||||
| # serve with hot reload at localhost:8080 | ||||
| npm run dev | ||||
| # build vue project using webpack | ||||
| yarn build | ||||
|  | ||||
| # build for production with minification | ||||
| npm run build | ||||
| # test or host built files using docker, might require sudo: | ||||
| docker build -t seasoned . | ||||
| docker run -d -p 5000:5000 --name seasoned-request --env-file .env seasoned | ||||
| ``` | ||||
|  | ||||
| For detailed explanation on how things work, consult the [docs for vue-loader](http://vuejs.github.io/vue-loader). | ||||
| This app uses [history mode](https://router.vuejs.org/en/essentials/history-mode.html) | ||||
| ## Development Steps | ||||
|  | ||||
| ```bash | ||||
| # serve project with hot reloading at localhost:8080 | ||||
| yarn dev | ||||
| ``` | ||||
|  | ||||
| To proxy requests to `/api` either update `SEASONED_API` in `.env` or run set environment variable, e.g.: | ||||
|  | ||||
| ```bash | ||||
| # export and run | ||||
| export SEASONED_API=http://localhost:31459 | ||||
| yarn dev | ||||
|  | ||||
| # or run with environment variable inline | ||||
| SEASONED_API=http://localhost:31459 yarn dev | ||||
| ``` | ||||
|  | ||||
| ## Documentation | ||||
|  | ||||
| All api functions are documented in `/docs` and [found here](docs/api.md).   | ||||
| [html version also available](http://htmlpreview.github.io/?https://github.com/KevinMidboe/seasoned/blob/release/v2/docs/api/index.html) | ||||
|  | ||||
| ## License | ||||
|  | ||||
| [MIT](https://github.com/dmtrbrl/tmdb-app/blob/master/LICENSE) | ||||
|   | ||||
							
								
								
									
										9
									
								
								docker-entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,9 @@ | ||||
| #!/bin/sh | ||||
| set -eu | ||||
|  | ||||
| export SEASONED_API=${SEASONED_API:-http://localhost:31459} | ||||
| export SEASONED_DOMAIN=${SEASONED_DOMAIN:-localhost} | ||||
|  | ||||
| envsubst '$SEASONED_API,$SEASONED_DOMAIN' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf | ||||
|  | ||||
| exec "$@" | ||||
							
								
								
									
										133
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
							
								
								
									
										30
									
								
								nginx.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,30 @@ | ||||
| server { | ||||
|   listen 5000 default_server; | ||||
|   listen [::]:5000 default_server; | ||||
|  | ||||
|   server_name $SEASONED_DOMAIN; | ||||
|   root /usr/share/nginx/html; | ||||
|  | ||||
|   gzip on; | ||||
|   gzip_types application/javascript; | ||||
|   gzip_min_length 1000; | ||||
|   gzip_static on; | ||||
|  | ||||
|   location /favicons { | ||||
|     autoindex on; | ||||
|   } | ||||
|  | ||||
|   location /dist { | ||||
|     add_header Content-Type application/javascript; | ||||
|     try_files $uri =404; | ||||
|   } | ||||
|  | ||||
|   location /api { | ||||
|     proxy_pass $SEASONED_API; | ||||
|   } | ||||
|  | ||||
|   location / { | ||||
|     try_files $uri $uri/ /index.html; | ||||
|     index index.html; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										81
									
								
								package.json
									
									
									
									
									
								
							
							
						
						| @@ -1,44 +1,61 @@ | ||||
| { | ||||
|   "name": "seasoned-request", | ||||
|   "description": "seasoned request app", | ||||
|   "version": "1.0.0", | ||||
|   "version": "1.22.17", | ||||
|   "author": "Kevin Midboe", | ||||
|   "private": true, | ||||
|   "scripts": { | ||||
|     "dev": "cross-env NODE_ENV=development webpack-dev-server --hot", | ||||
|     "build": "cross-env NODE_ENV=production webpack --progress --hide-modules", | ||||
|     "start": "node server.js", | ||||
|     "docs": "documentation build src/api.js -f html -o docs/api && documentation build src/api.js -f md -o docs/api.md" | ||||
|     "dev": "NODE_ENV=development webpack server", | ||||
|     "build": "yarn build:ts && yarn build:webpack", | ||||
|     "build:ts": "tsc --project tsconfig.json", | ||||
|     "build:webpack": "NODE_ENV=production webpack-cli build --progress", | ||||
|     "postbuild": "cp public/dist/index.html public/index.html", | ||||
|     "clean": "rm -r public/dist 2> /dev/null; rm public/index.html 2> /dev/null; rm -r lib 2> /dev/null", | ||||
|     "start": "echo 'Start using docker, consult README'", | ||||
|     "lint": "eslint src --ext .ts,.vue", | ||||
|     "docs": "documentation build src/api.ts -f html -o docs/api && documentation build src/api.ts -f md -o docs/api.md" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "axios": "^0.18.1", | ||||
|     "babel-plugin-transform-object-rest-spread": "^6.26.0", | ||||
|     "connect-history-api-fallback": "^1.3.0", | ||||
|     "express": "^4.16.1", | ||||
|     "vue": "^2.5.2", | ||||
|     "vue-axios": "^1.2.2", | ||||
|     "vue-data-tablee": "^0.12.1", | ||||
|     "vue-js-modal": "^1.3.16", | ||||
|     "vue-router": "^3.0.1", | ||||
|     "vuex": "^3.1.0" | ||||
|     "chart.js": "3.9.1", | ||||
|     "connect-history-api-fallback": "2.0.0", | ||||
|     "dotenv": "^16.0.1", | ||||
|     "express": "4.18.1", | ||||
|     "vue": "3.2.37", | ||||
|     "vue-router": "4.1.3", | ||||
|     "vuex": "4.0.2" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "^7.4.5", | ||||
|     "@babel/plugin-transform-runtime": "^7.4.4", | ||||
|     "@babel/preset-env": "^7.4.5", | ||||
|     "@babel/runtime": "^7.4.5", | ||||
|     "babel-loader": "^8.0.6", | ||||
|     "cross-env": "^3.0.0", | ||||
|     "css-loader": "^0.25.0", | ||||
|     "documentation": "^11.0.0", | ||||
|     "file-loader": "^0.9.0", | ||||
|     "node-sass": "^4.5.0", | ||||
|     "sass-loader": "^5.0.1", | ||||
|     "schema-utils": "^2.4.1", | ||||
|     "vue-loader": "^10.0.0", | ||||
|     "vue-svg-inline-loader": "^1.3.1", | ||||
|     "vue-template-compiler": "2.6.10", | ||||
|     "webpack": "^2.2.0", | ||||
|     "webpack-dev-server": "^2.2.0" | ||||
|     "@babel/core": "7.18.10", | ||||
|     "@babel/plugin-transform-runtime": "7.18.10", | ||||
|     "@babel/preset-env": "7.18.10", | ||||
|     "@babel/runtime": "7.18.9", | ||||
|     "@types/express": "4.17.13", | ||||
|     "@types/node": "18.6.1", | ||||
|     "@typescript-eslint/eslint-plugin": "5.33.0", | ||||
|     "@typescript-eslint/parser": "5.33.0", | ||||
|     "@vue/cli": "5.0.8", | ||||
|     "@vue/cli-service": "5.0.8", | ||||
|     "@vue/eslint-config-airbnb": "6.0.0", | ||||
|     "babel-loader": "8.2.5", | ||||
|     "css-loader": "6.7.1", | ||||
|     "documentation": "13.2.5", | ||||
|     "eslint": "8.21.0", | ||||
|     "eslint-config-prettier": "8.5.0", | ||||
|     "eslint-plugin-import": "2.26.0", | ||||
|     "eslint-plugin-prettier": "4.2.1", | ||||
|     "eslint-plugin-vue": "9.3.0", | ||||
|     "eslint-plugin-vuejs-accessibility": "1.2.0", | ||||
|     "file-loader": "6.2.0", | ||||
|     "html-webpack-plugin": "5.5.0", | ||||
|     "prettier": "2.7.1", | ||||
|     "sass": "1.54.3", | ||||
|     "sass-loader": "13.0.2", | ||||
|     "terser-webpack-plugin": "5.3.3", | ||||
|     "ts-loader": "9.3.1", | ||||
|     "typescript": "4.7.4", | ||||
|     "vue-loader": "17.0.0", | ||||
|     "webpack": "5.74.0", | ||||
|     "webpack-cli": "4.10.0", | ||||
|     "webpack-dev-server": "4.9.3" | ||||
|   } | ||||
| } | ||||
|   | ||||
| Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB | 
| Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/assets/dune.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 641 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/assets/mandalorian.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 331 KiB | 
							
								
								
									
										13
									
								
								public/assets/no-image.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.3 KiB | 
							
								
								
									
										13
									
								
								public/assets/no-image_small.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.3 KiB | 
| Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB | 
| Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 275 KiB | 
| Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 423 KiB | 
| Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB | 
| Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB | 
| Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB | 
| Before Width: | Height: | Size: 889 B After Width: | Height: | Size: 889 B | 
| Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB | 
| Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB | 
| Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB | 
| Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										23
									
								
								server.js
									
									
									
									
									
								
							
							
						
						| @@ -1,23 +0,0 @@ | ||||
| var express = require('express'); | ||||
| var path = require('path'); | ||||
| const compression = require('compression') | ||||
| var history = require('connect-history-api-fallback'); | ||||
|  | ||||
| app = express(); | ||||
|  | ||||
| app.use(compression()) | ||||
| app.use('/dist', express.static(path.join(__dirname + "/dist"))); | ||||
| app.use('/dist', express.static(path.join(__dirname + "/dist/"))); | ||||
| app.use('/favicons', express.static(path.join(__dirname + "/favicons"))); | ||||
| app.use(history({ | ||||
|     index: '/' | ||||
| })); | ||||
|  | ||||
| var port = process.env.PORT || 5000; | ||||
|  | ||||
| app.get('/', function(req, res) { | ||||
|     res.sendFile(path.join(__dirname + '/index.html')); | ||||
| }); | ||||
|  | ||||
|  | ||||
| app.listen(port); | ||||
							
								
								
									
										191
									
								
								src/App.vue
									
									
									
									
									
								
							
							
						
						| @@ -1,155 +1,70 @@ | ||||
| <template> | ||||
|   <div id="app"> | ||||
|  | ||||
|     <!-- Header and hamburger navigation --> | ||||
|     <navigation></navigation> | ||||
|     <NavigationHeader class="header" /> | ||||
|  | ||||
|     <!-- Header with search field --> | ||||
|  | ||||
|     <!-- TODO move this to the navigation component --> | ||||
|     <header class="header"> | ||||
|       <search-input v-model="query"></search-input> | ||||
|     </header> | ||||
|  | ||||
|     <!-- Movie popup that will show above existing rendered content --> | ||||
|     <movie-popup v-if="moviePopupIsVisible" :id="popupID" :type="popupType"></movie-popup> | ||||
|  | ||||
|  | ||||
|     <darkmode-toggle /> | ||||
|     <div class="navigation-icons-gutter desktop-only"> | ||||
|       <NavigationIcons /> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Display the component assigned to the given route (default: home) --> | ||||
|     <router-view class="content" :key="$route.fullPath"></router-view> | ||||
|     <router-view :key="router.currentRoute.value.path" class="content" /> | ||||
|  | ||||
|     <!-- Popup that will show above existing rendered content --> | ||||
|     <popup /> | ||||
|  | ||||
|     <darkmode-toggle /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import Vue from 'vue' | ||||
| import Navigation from '@/components/Navigation' | ||||
| import MoviePopup from '@/components/MoviePopup' | ||||
| import SearchInput from '@/components/SearchInput' | ||||
| import DarkmodeToggle from '@/components/ui/darkmodeToggle' | ||||
| <script setup lang="ts"> | ||||
|   import { useRouter } from "vue-router"; | ||||
|   import NavigationHeader from "@/components/header/NavigationHeader.vue"; | ||||
|   import NavigationIcons from "@/components/header/NavigationIcons.vue"; | ||||
|   import Popup from "@/components/Popup.vue"; | ||||
|   import DarkmodeToggle from "@/components/ui/DarkmodeToggle.vue"; | ||||
|  | ||||
| export default { | ||||
|   name: 'app', | ||||
|   components: { | ||||
|     Navigation, | ||||
|     MoviePopup, | ||||
|     SearchInput, | ||||
|     DarkmodeToggle | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       query: '', | ||||
|       moviePopupIsVisible: false, | ||||
|       popupID: 0, | ||||
|       popupType: 'movie' | ||||
|     } | ||||
|   }, | ||||
|   created(){ | ||||
|     let that = this | ||||
|     Vue.prototype.$popup = { | ||||
|       get isOpen() { | ||||
|         return that.moviePopupIsVisible | ||||
|       }, | ||||
|       open: (id, type) => { | ||||
|         this.popupID = id || this.popupID | ||||
|         this.popupType = type || this.popupType | ||||
|         this.moviePopupIsVisible = true | ||||
|         console.log('opened') | ||||
|       }, | ||||
|       close: () => { | ||||
|         this.moviePopupIsVisible = false | ||||
|         console.log('closed') | ||||
|       } | ||||
|     } | ||||
|     console.log('MoviePopup registered at this.$popup and has state: ', this.$popup.isOpen) | ||||
|   } | ||||
| } | ||||
|   const router = useRouter(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/media-queries"; | ||||
| @import "./src/scss/variables"; | ||||
| .content { | ||||
|     @include tablet-min{ | ||||
|     width: calc(100% - 95px); | ||||
|     margin-top: $header-size; | ||||
|     margin-left: 95px; | ||||
|     position: relative; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|  | ||||
| <style lang="scss"> | ||||
| // @import "./src/scss/main"; | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| *{ | ||||
|   box-sizing: border-box; | ||||
| } | ||||
| html { | ||||
|   height: 100%; | ||||
| } | ||||
| body{ | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   font-family: 'Roboto', sans-serif; | ||||
|   line-height: 1.6; | ||||
|   background: $background-color; | ||||
|   color: $text-color; | ||||
|   transition: background-color .5s ease, color .5s ease; | ||||
|   &.hidden{ | ||||
|     overflow: hidden; | ||||
|   #app { | ||||
|     display: grid; | ||||
|     grid-template-rows: var(--header-size); | ||||
|     grid-template-columns: var(--header-size) 1fr; | ||||
|  | ||||
|     @include mobile { | ||||
|       grid-template-columns: 1fr; | ||||
|     } | ||||
|  | ||||
|     .header { | ||||
|       position: fixed; | ||||
|       top: 0; | ||||
|       width: 100%; | ||||
|       z-index: 15; | ||||
|     } | ||||
|  | ||||
|     .navigation-icons-gutter { | ||||
|       position: fixed; | ||||
|       height: 100vh; | ||||
|       margin: 0; | ||||
|       top: var(--header-size); | ||||
|       width: var(--header-size); | ||||
|       background-color: var(--background-color-secondary); | ||||
|     } | ||||
|  | ||||
|     .content { | ||||
|       display: grid; | ||||
|       grid-column: 2 / 3; | ||||
|       grid-row: 2; | ||||
|       z-index: 5; | ||||
|  | ||||
|       @include mobile { | ||||
|         grid-column: 1 / 3; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| h1,h2,h3 { | ||||
|   transition: color .5s ease; | ||||
| } | ||||
| a:any-link { | ||||
|   color: inherit; | ||||
| } | ||||
| input, textarea, button{ | ||||
|   font-family: 'Roboto', sans-serif; | ||||
| } | ||||
| figure{ | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
| } | ||||
| img{ | ||||
|   display: block; | ||||
|   // max-width: 100%; | ||||
|   height: auto; | ||||
| } | ||||
|  | ||||
| .wrapper{ | ||||
|   position: relative; | ||||
| } | ||||
| .header{ | ||||
|   position: fixed; | ||||
|   z-index: 15; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|  | ||||
|   @include tablet-min{ | ||||
|     width: calc(100% - 170px); | ||||
|     margin-left: 95px; | ||||
|     border-top: 0; | ||||
|     border-bottom: 0; | ||||
|     top: 0; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // router view transition | ||||
| .fade-enter-active, .fade-leave-active { | ||||
|   transition-property: opacity; | ||||
|   transition-duration: 0.25s; | ||||
| } | ||||
| .fade-enter-active { | ||||
|   transition-delay: 0.25s; | ||||
| } | ||||
| .fade-enter, .fade-leave-active { | ||||
|   opacity: 0 | ||||
| } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										309
									
								
								src/api.js
									
									
									
									
									
								
							
							
						
						| @@ -1,309 +0,0 @@ | ||||
| import axios from 'axios' | ||||
| import storage from '@/storage' | ||||
| import config from '@/config.json' | ||||
| import path from 'path' | ||||
|  | ||||
| const SEASONED_URL = config.SEASONED_URL | ||||
| const ELASTIC_URL = config.ELASTIC_URL | ||||
| const ELASTIC_INDEX = config.ELASTIC_INDEX | ||||
|  | ||||
| // TODO | ||||
| //  - Move autorization token and errors here? | ||||
|  | ||||
| // - - - TMDB - - -  | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb movie by id. Can optionally include cast credits in result object. | ||||
|  * @param {number} id | ||||
|  * @param {boolean} [credits=false] Include credits | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const getMovie = (id, credits=false) => { | ||||
|   const url = new URL('v2/movie', SEASONED_URL) | ||||
|   url.pathname = path.join(url.pathname, id.toString()) | ||||
|   if (credits) { | ||||
|     url.searchParams.append('credits', true) | ||||
|   } | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { console.error(`api error getting movie: ${id}`); throw error }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb show by id. Can optionally include cast credits in result object. | ||||
|  * @param {number} id | ||||
|  * @param {boolean} [credits=false] Include credits | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const getShow = (id, credits=false) => { | ||||
|   const url = new URL('v2/show', SEASONED_URL) | ||||
|   url.pathname = path.join(url.pathname, id.toString()) | ||||
|   if (credits) { | ||||
|     url.searchParams.append('credits', true) | ||||
|   } | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { console.error(`api error getting show: ${id}`); throw error }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb list by name. | ||||
|  * @param {string} name List the fetch | ||||
|  * @param {number} [page=1] | ||||
|  * @returns {object} Tmdb list response | ||||
|  */ | ||||
| const getTmdbMovieListByName = (name, page=1) => { | ||||
|   const url = new URL('v2/movie/' + name, SEASONED_URL) | ||||
|   url.searchParams.append('page', page) | ||||
|   const headers = { authorization: storage.token } | ||||
|  | ||||
|   return fetch(url.href, { headers: headers }) | ||||
|     .then(resp => resp.json()) | ||||
|     // .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Fetches requested items. | ||||
|  * @param {number} [page=1] | ||||
|  * @returns {object} Request response | ||||
|  */ | ||||
| const getRequests = (page=1) => { | ||||
|   const url = new URL('v2/request', SEASONED_URL) | ||||
|   url.searchParams.append('page', page) | ||||
|   const headers = { authorization: storage.token } | ||||
|  | ||||
|   return fetch(url.href, { headers: headers }) | ||||
|     .then(resp => resp.json()) | ||||
|     // .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) | ||||
| } | ||||
|  | ||||
|  | ||||
| const getUserRequests = (page=1) => { | ||||
|   const url = new URL('v1/user/requests', SEASONED_URL) | ||||
|   url.searchParams.append('page', page) | ||||
|  | ||||
|   const headers = { authorization: localStorage.getItem('token') } | ||||
|  | ||||
|   return fetch(url.href, { headers }) | ||||
|     .then(resp => resp.json()) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb movies and shows by query. | ||||
|  * @param {string} query | ||||
|  * @param {number} [page=1] | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const searchTmdb = (query, page=1) => { | ||||
|   const url = new URL('v2/search', SEASONED_URL) | ||||
|   url.searchParams.append('query', query) | ||||
|   url.searchParams.append('page', page) | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { console.error(`api error searching: ${query}, page: ${page}`); throw error }) | ||||
| } | ||||
|  | ||||
| // - - - Torrents - - -  | ||||
|  | ||||
| /** | ||||
|  * Search for torrents by query | ||||
|  * @param {string} query | ||||
|  * @param {boolean} credits Include credits | ||||
|  * @returns {object} Torrent response | ||||
|  */ | ||||
| const searchTorrents = (query, authorization_token) => { | ||||
|   const url = new URL('/api/v1/pirate/search', SEASONED_URL) | ||||
|   url.searchParams.append('query', query) | ||||
|  | ||||
|   const headers = { authorization: storage.token } | ||||
|  | ||||
|   return fetch(url.href, { headers: headers }) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { console.error(`api error searching torrents: ${query}`); throw error }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Add magnet to download queue. | ||||
|  * @param {string} magnet Magnet link | ||||
|  * @param {boolean} name Name of torrent | ||||
|  * @param {boolean} tmdb_id | ||||
|  * @returns {object} Success/Failure response | ||||
|  */ | ||||
| const addMagnet = (magnet, name, tmdb_id) => { | ||||
|   const url = new URL('v1/pirate/add', SEASONED_URL) | ||||
|  | ||||
|   const body = { | ||||
|     magnet: magnet, | ||||
|     name: name, | ||||
|     tmdb_id: tmdb_id | ||||
|   } | ||||
|   const headers = { authorization: storage.token } | ||||
|  | ||||
|   return fetch(url.href, { method: 'POST', headers, body }) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { console.error(`api error adding magnet: ${name} ${error}`); throw error }) | ||||
| } | ||||
|  | ||||
| // - - - Plex/Request - - -  | ||||
|  | ||||
| /** | ||||
|  * Request a movie or show from id. If authorization token is included the user will be linked | ||||
|  * to the requested item. | ||||
|  * @param {number} id Movie or show id | ||||
|  * @param {string} type Movie or show type | ||||
|  * @param {string} [authorization_token] To identify the requesting user | ||||
|  * @returns {object} Success/Failure response | ||||
|  */ | ||||
| const request = (id, type, authorization_token=undefined) => { | ||||
|   const url = new URL('v2/request', SEASONED_URL) | ||||
| //  url.pathname = path.join(url.pathname, id.toString()) | ||||
| //  url.searchParams.append('type', type) | ||||
|  | ||||
|   const headers = { | ||||
|     'Authorization': authorization_token, | ||||
|     'Content-Type': 'application/json' | ||||
|   } | ||||
|   const body = { | ||||
|     id: id, | ||||
|     type: type | ||||
|   } | ||||
|  | ||||
|   return fetch(url.href, { | ||||
|     method: 'POST', | ||||
|     headers: headers, | ||||
|     body: JSON.stringify(body) | ||||
|   }) | ||||
|   .then(resp => resp.json()) | ||||
|   .catch(error => { console.error(`api error requesting: ${id}, type: ${type}`); throw error }) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Check request status by tmdb id and type | ||||
|  * @param {number} tmdb id | ||||
|  * @param {string} type | ||||
|  * @returns {object} Success/Failure response | ||||
|  */ | ||||
| const getRequestStatus = (id, type, authorization_token=undefined) => { | ||||
|   const url = new URL('v2/request', SEASONED_URL) | ||||
|   url.pathname = path.join(url.pathname, id.toString()) | ||||
|   url.searchParams.append('type', type) | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => { | ||||
|       const status = resp.status; | ||||
|       if (status === 200) { return true } | ||||
|       else if (status === 404) { return false } | ||||
|       else { | ||||
|         console.error(`api error getting request status for id ${id} and type ${type}`) | ||||
|       } | ||||
|     }) | ||||
|     .catch(err => Promise.reject(err)) | ||||
| } | ||||
|  | ||||
| // - - - Authenticate with plex - - - | ||||
|  | ||||
| const plexAuthenticate = (username, password) => { | ||||
|   const url = new URL('https://plex.tv/api/v2/users/signin') | ||||
|  | ||||
|   const headers = { | ||||
|     'Content-Type': 'application/json', | ||||
|     'X-Plex-Platform': 'Linux', | ||||
|     'X-Plex-Version': 'v2.0.24', | ||||
|     'X-Plex-Platform-Version': '4.13.0-36-generic', | ||||
|     'X-Plex-Device-Name': 'Tautulli', | ||||
|     'X-Plex-Client-Identifier': '123' | ||||
|   } | ||||
|  | ||||
|   let formData = new FormData() | ||||
|   formData.set('login', username) | ||||
|   formData.set('password', password) | ||||
|   formData.set('rememberMe', false) | ||||
|  | ||||
|   return axios({ | ||||
|       method: 'POST', | ||||
|       url: url.href, | ||||
|       headers: headers, | ||||
|       data: formData | ||||
|     }) | ||||
|     .catch(error => { console.error(`api error authentication plex: ${username}`); throw error }) | ||||
| } | ||||
|  | ||||
|  | ||||
| // - - - Random emoji - - - | ||||
|  | ||||
| const getEmoji = () => { | ||||
|   const url = new URL('v1/emoji', SEASONED_URL) | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { console.log('api error getting emoji'); throw error }) | ||||
| } | ||||
|  | ||||
|  | ||||
| // - - - ELASTIC SEARCH - - - | ||||
| // This elastic index contains titles mapped to ids. Lightning search | ||||
| // used for autocomplete | ||||
|  | ||||
| /** | ||||
|  * Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and | ||||
|  * Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports | ||||
|  * @param {string} query | ||||
|  * @returns {object} List of movies and shows matching query | ||||
|  */ | ||||
| const elasticSearchMoviesAndShows = (query) => { | ||||
|   const url = new URL(path.join(ELASTIC_INDEX, '/_search'), ELASTIC_URL) | ||||
|   const headers = { | ||||
|     'Content-Type': 'application/json' | ||||
|   } | ||||
|  | ||||
|   const body = { | ||||
|     "sort" : [ | ||||
|       { "popularity" : {"order" : "desc"}}, | ||||
|       "_score" | ||||
|     ], | ||||
|     "query": { | ||||
|       "bool": { | ||||
|         "should": [{ | ||||
|           "match_phrase_prefix": { | ||||
|             "original_name": query | ||||
|           } | ||||
|         }, | ||||
|         { | ||||
|           "match_phrase_prefix": { | ||||
|             "original_title": query | ||||
|           } | ||||
|         }] | ||||
|       } | ||||
|     }, | ||||
|     "size": 6 | ||||
|   } | ||||
|  | ||||
|   return fetch(url.href, { | ||||
|     method: 'POST', | ||||
|     headers: headers, | ||||
|     body: JSON.stringify(body) | ||||
|   }) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { console.log(`api error searching elasticsearch: ${query}`); throw error }) | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| export { | ||||
|   getMovie, | ||||
|   getShow, | ||||
|   getTmdbMovieListByName, | ||||
|   searchTmdb, | ||||
|   getUserRequests, | ||||
|   getRequests, | ||||
|   searchTorrents, | ||||
|   addMagnet, | ||||
|   request, | ||||
|   getRequestStatus, | ||||
|   plexAuthenticate, | ||||
|   getEmoji, | ||||
|   elasticSearchMoviesAndShows | ||||
| } | ||||
							
								
								
									
										536
									
								
								src/api.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,536 @@ | ||||
| import { IList, IMediaCredits, IPersonCredits } from "./interfaces/IList"; | ||||
| import type { | ||||
|   IRequestStatusResponse, | ||||
|   IRequestSubmitResponse | ||||
| } from "./interfaces/IRequestResponse"; | ||||
|  | ||||
| const { ELASTIC, ELASTIC_INDEX } = process.env; | ||||
| const API_HOSTNAME = window.location.origin; | ||||
|  | ||||
| // - - - TMDB - - - | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb movie by id. Can optionally include cast credits in result object. | ||||
|  * @param {number} id | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const getMovie = ( | ||||
|   id, | ||||
|   { | ||||
|     checkExistance, | ||||
|     credits, | ||||
|     releaseDates | ||||
|   }: { checkExistance: boolean; credits: boolean; releaseDates?: boolean } | ||||
| ) => { | ||||
|   const url = new URL("/api/v2/movie", API_HOSTNAME); | ||||
|   url.pathname = `${url.pathname}/${id.toString()}`; | ||||
|   if (checkExistance) { | ||||
|     url.searchParams.append("check_existance", "true"); | ||||
|   } | ||||
|   if (credits) { | ||||
|     url.searchParams.append("credits", "true"); | ||||
|   } | ||||
|   if (releaseDates) { | ||||
|     url.searchParams.append("release_dates", "true"); | ||||
|   } | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb show by id. Can optionally include cast credits in result object. | ||||
|  * @param {number} id | ||||
|  * @param {boolean} [credits=false] Include credits | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const getShow = ( | ||||
|   id, | ||||
|   { | ||||
|     checkExistance, | ||||
|     credits, | ||||
|     releaseDates | ||||
|   }: { checkExistance: boolean; credits: boolean; releaseDates?: boolean } | ||||
| ) => { | ||||
|   const url = new URL("/api/v2/show", API_HOSTNAME); | ||||
|   url.pathname = `${url.pathname}/${id.toString()}`; | ||||
|   if (checkExistance) { | ||||
|     url.searchParams.append("check_existance", "true"); | ||||
|   } | ||||
|   if (credits) { | ||||
|     url.searchParams.append("credits", "true"); | ||||
|   } | ||||
|   if (releaseDates) { | ||||
|     url.searchParams.append("release_dates", "true"); | ||||
|   } | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting show: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb person by id. Can optionally include cast credits in result object. | ||||
|  * @param {number} id | ||||
|  * @param {boolean} [credits=false] Include credits | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const getPerson = (id, credits = false) => { | ||||
|   const url = new URL("/api/v2/person", API_HOSTNAME); | ||||
|   url.pathname = `${url.pathname}/${id.toString()}`; | ||||
|   if (credits) { | ||||
|     url.searchParams.append("credits", "true"); | ||||
|   } | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting person: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb movie credits by id. | ||||
|  * @param {number} id | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const getMovieCredits = (id: number): Promise<IMediaCredits> => { | ||||
|   const url = new URL("/api/v2/movie", API_HOSTNAME); | ||||
|   url.pathname = `${url.pathname}/${id.toString()}/credits`; | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb show credits by id. | ||||
|  * @param {number} id | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const getShowCredits = (id: number): Promise<IMediaCredits> => { | ||||
|   const url = new URL("/api/v2/show", API_HOSTNAME); | ||||
|   url.pathname = `${url.pathname}/${id.toString()}/credits`; | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting show: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb person credits by id. | ||||
|  * @param {number} id | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const getPersonCredits = (id: number): Promise<IPersonCredits> => { | ||||
|   const url = new URL("/api/v2/person", API_HOSTNAME); | ||||
|   url.pathname = `${url.pathname}/${id.toString()}/credits`; | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error getting person: ${id}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb list by name. | ||||
|  * @param {string} name List the fetch | ||||
|  * @param {number} [page=1] | ||||
|  * @returns {object} Tmdb list response | ||||
|  */ | ||||
| const getTmdbMovieListByName = (name: string, page = 1): Promise<IList> => { | ||||
|   const url = new URL(`/api/v2/movie/${name}`, API_HOSTNAME); | ||||
|   url.searchParams.append("page", page.toString()); | ||||
|  | ||||
|   return fetch(url.href).then(resp => resp.json()); | ||||
|   // .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Fetches requested items. | ||||
|  * @param {number} [page=1] | ||||
|  * @returns {object} Request response | ||||
|  */ | ||||
| const getRequests = (page = 1) => { | ||||
|   const url = new URL("/api/v2/request", API_HOSTNAME); | ||||
|   url.searchParams.append("page", page.toString()); | ||||
|  | ||||
|   return fetch(url.href).then(resp => resp.json()); | ||||
|   // .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console | ||||
| }; | ||||
|  | ||||
| const getUserRequests = (page = 1) => { | ||||
|   const url = new URL("/api/v1/user/requests", API_HOSTNAME); | ||||
|   url.searchParams.append("page", page.toString()); | ||||
|  | ||||
|   return fetch(url.href).then(resp => resp.json()); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Fetches tmdb movies and shows by query. | ||||
|  * @param {string} query | ||||
|  * @param {number} [page=1] | ||||
|  * @returns {object} Tmdb response | ||||
|  */ | ||||
| const searchTmdb = (query, page = 1, adult = false, mediaType = null) => { | ||||
|   const url = new URL("/api/v2/search", API_HOSTNAME); | ||||
|   if (mediaType != null && ["movie", "show", "person"].includes(mediaType)) { | ||||
|     url.pathname += `/${mediaType}`; | ||||
|   } | ||||
|  | ||||
|   url.searchParams.append("query", query); | ||||
|   url.searchParams.append("page", page.toString()); | ||||
|   url.searchParams.append("adult", adult.toString()); | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error searching: ${query}, page: ${page}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| // - - - Torrents - - - | ||||
|  | ||||
| /** | ||||
|  * Search for torrents by query | ||||
|  * @param {string} query | ||||
|  * @param {boolean} credits Include credits | ||||
|  * @returns {object} Torrent response | ||||
|  */ | ||||
| const searchTorrents = query => { | ||||
|   const url = new URL("/api/v1/pirate/search", API_HOSTNAME); | ||||
|   url.searchParams.append("query", query); | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error searching torrents: ${query}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Add magnet to download queue. | ||||
|  * @param {string} magnet Magnet link | ||||
|  * @param {boolean} name Name of torrent | ||||
|  * @param {boolean} tmdbId | ||||
|  * @returns {object} Success/Failure response | ||||
|  */ | ||||
| const addMagnet = (magnet: string, name: string, tmdbId: number | null) => { | ||||
|   const url = new URL("/api/v1/pirate/add", API_HOSTNAME); | ||||
|  | ||||
|   const options = { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify({ | ||||
|       magnet, | ||||
|       name, | ||||
|       tmdb_id: tmdbId | ||||
|     }) | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error adding magnet: ${name} ${error}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| // - - - Plex/Request - - - | ||||
|  | ||||
| /** | ||||
|  * Request a movie or show from id. If authorization token is included the user will be linked | ||||
|  * to the requested item. | ||||
|  * @param {number} id Movie or show id | ||||
|  * @param {string} type Movie or show type | ||||
|  * @returns {object} Success/Failure response | ||||
|  */ | ||||
| const request = (id, type): Promise<IRequestSubmitResponse> => { | ||||
|   const url = new URL("/api/v2/request", API_HOSTNAME); | ||||
|  | ||||
|   const options = { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify({ id, type }) | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error requesting: ${id}, type: ${type}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Check request status by tmdb id and type | ||||
|  * @param {number} tmdb id | ||||
|  * @param {string} type | ||||
|  * @returns {object} Success/Failure response | ||||
|  */ | ||||
| const getRequestStatus = (id, type = null): Promise<IRequestStatusResponse> => { | ||||
|   const url = new URL("/api/v2/request", API_HOSTNAME); | ||||
|   url.pathname = `${url.pathname}/${id.toString()}`; | ||||
|   url.searchParams.append("type", type); | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(err => Promise.reject(err)); | ||||
| }; | ||||
|  | ||||
| const watchLink = (title, year) => { | ||||
|   const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME); | ||||
|   url.searchParams.append("title", title); | ||||
|   url.searchParams.append("year", year); | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .then(response => response.link); | ||||
| }; | ||||
|  | ||||
| const movieImages = id => { | ||||
|   const url = new URL(`v2/movie/${id}/images`, API_HOSTNAME); | ||||
|  | ||||
|   return fetch(url.href).then(resp => resp.json()); | ||||
| }; | ||||
|  | ||||
| // - - - Seasoned user endpoints - - - | ||||
|  | ||||
| const register = (username, password) => { | ||||
|   const url = new URL("/api/v1/user", API_HOSTNAME); | ||||
|   const options = { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify({ username, password }) | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       const errorMessage = | ||||
|         "Unexpected error occured before receiving response. Error:"; | ||||
|       // eslint-disable-next-line no-console | ||||
|       console.error(errorMessage, error); | ||||
|       // TODO log to sentry the issue here | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| const login = (username, password, throwError = false) => { | ||||
|   const url = new URL("/api/v1/user/login", API_HOSTNAME); | ||||
|   const options = { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify({ username, password }) | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options).then(resp => { | ||||
|     if (resp.status === 200) return resp.json(); | ||||
|  | ||||
|     if (throwError) throw resp; | ||||
|     console.error("Error occured when trying to sign in.\nError:", resp); // eslint-disable-line no-console | ||||
|     return Promise.reject(resp); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const logout = (throwError = false) => { | ||||
|   const url = new URL("/api/v1/user/logout", API_HOSTNAME); | ||||
|   const options = { method: "POST" }; | ||||
|  | ||||
|   return fetch(url.href, options).then(resp => { | ||||
|     if (resp.status === 200) return resp.json(); | ||||
|  | ||||
|     if (throwError) throw resp; | ||||
|     console.error("Error occured when trying to log out.\nError:", resp); // eslint-disable-line no-console | ||||
|     return Promise.reject(resp); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const getSettings = () => { | ||||
|   const url = new URL("/api/v1/user/settings", API_HOSTNAME); | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.log("api error getting user settings"); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| const updateSettings = settings => { | ||||
|   const url = new URL("/api/v1/user/settings", API_HOSTNAME); | ||||
|  | ||||
|   const options = { | ||||
|     method: "PUT", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify(settings) | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.log("api error updating user settings"); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| // - - - Authenticate with plex - - - | ||||
|  | ||||
| const linkPlexAccount = (username, password) => { | ||||
|   const url = new URL("/api/v1/user/link_plex", API_HOSTNAME); | ||||
|   const body = { username, password }; | ||||
|  | ||||
|   const options = { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify(body) | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error linking plex account: ${username}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| const unlinkPlexAccount = () => { | ||||
|   const url = new URL("/api/v1/user/unlink_plex", API_HOSTNAME); | ||||
|  | ||||
|   const options = { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" } | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.error(`api error unlinking your plex account`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| // - - - User graphs - - - | ||||
|  | ||||
| const fetchGraphData = (urlPath, days, chartType) => { | ||||
|   const url = new URL(`/api/v1/user/${urlPath}`, API_HOSTNAME); | ||||
|   url.searchParams.append("days", days); | ||||
|   url.searchParams.append("y_axis", chartType); | ||||
|  | ||||
|   return fetch(url.href).then(resp => { | ||||
|     if (!resp.ok) { | ||||
|       console.log("DAMN WE FAILED!", resp); // eslint-disable-line no-console | ||||
|       throw Error(resp.statusText); | ||||
|     } | ||||
|  | ||||
|     return resp.json(); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| // - - - Random emoji - - - | ||||
|  | ||||
| const getEmoji = () => { | ||||
|   const url = new URL("/api/v1/emoji", API_HOSTNAME); | ||||
|  | ||||
|   return fetch(url.href) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.log("api error getting emoji"); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| // - - - ELASTIC SEARCH - - - | ||||
| // This elastic index contains titles mapped to ids. Lightning search | ||||
| // used for autocomplete | ||||
|  | ||||
| /** | ||||
|  * Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and | ||||
|  * Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports | ||||
|  * @param {string} query | ||||
|  * @returns {object} List of movies and shows matching query | ||||
|  */ | ||||
| const elasticSearchMoviesAndShows = (query, count = 22) => { | ||||
|   const url = new URL(`${ELASTIC_INDEX}/_search`, ELASTIC); | ||||
|  | ||||
|   const body = { | ||||
|     sort: [{ popularity: { order: "desc" } }, "_score"], | ||||
|     query: { | ||||
|       bool: { | ||||
|         should: [ | ||||
|           { | ||||
|             match_phrase_prefix: { | ||||
|               original_name: query | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             match_phrase_prefix: { | ||||
|               original_title: query | ||||
|             } | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     size: count | ||||
|   }; | ||||
|  | ||||
|   const options = { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify(body) | ||||
|   }; | ||||
|  | ||||
|   return fetch(url.href, options) | ||||
|     .then(resp => resp.json()) | ||||
|     .catch(error => { | ||||
|       console.log(`api error searching elasticsearch: ${query}`); // eslint-disable-line no-console | ||||
|       throw error; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| export { | ||||
|   getMovie, | ||||
|   getShow, | ||||
|   getPerson, | ||||
|   getMovieCredits, | ||||
|   getShowCredits, | ||||
|   getPersonCredits, | ||||
|   getTmdbMovieListByName, | ||||
|   searchTmdb, | ||||
|   getUserRequests, | ||||
|   getRequests, | ||||
|   searchTorrents, | ||||
|   addMagnet, | ||||
|   request, | ||||
|   watchLink, | ||||
|   movieImages, | ||||
|   getRequestStatus, | ||||
|   linkPlexAccount, | ||||
|   unlinkPlexAccount, | ||||
|   register, | ||||
|   login, | ||||
|   logout, | ||||
|   getSettings, | ||||
|   updateSettings, | ||||
|   fetchGraphData, | ||||
|   getEmoji, | ||||
|   elasticSearchMoviesAndShows | ||||
| }; | ||||
| Before Width: | Height: | Size: 1.9 KiB | 
| @@ -1,39 +0,0 @@ | ||||
| <template> | ||||
|   <section class="not-found"> | ||||
|     <h1 class="not-found__title">Page Not Found</h1> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|  | ||||
| .not-found { | ||||
|   display: flex; | ||||
|   height: calc(100vh - var(--header-size)); | ||||
|   width: 100%; | ||||
|   background: url('~assets/pulp-fiction.jpg') no-repeat 50% 50%; | ||||
|   background-size: cover; | ||||
|   justify-content: center; | ||||
|  | ||||
|   &:before { | ||||
|     content: ""; | ||||
|     position: absolute; | ||||
|     height: calc(100vh - var(--header-size)); | ||||
|     width: 100%; | ||||
|     background: $background-40; | ||||
|   } | ||||
|   &__title { | ||||
|    padding-top: 40vh; | ||||
|     font-size: 2rem; | ||||
|     font-weight: 500; | ||||
|     color: $text-color; | ||||
|     position: relative; | ||||
|     margin: 0; | ||||
|  | ||||
|     @include tablet-min { | ||||
|       font-size: 2.3rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										51
									
								
								src/components/CastList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,51 @@ | ||||
| <template> | ||||
|   <div class="cast"> | ||||
|     <ol class="persons"> | ||||
|       <CastListItem | ||||
|         v-for="credit in cast" | ||||
|         :key="credit.id" | ||||
|         :credit-item="credit" | ||||
|       /> | ||||
|     </ol> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|   import CastListItem from "src/components/CastListItem.vue"; | ||||
|   import type { | ||||
|     IMovie, | ||||
|     IShow, | ||||
|     IPerson, | ||||
|     ICast, | ||||
|     ICrew | ||||
|   } from "../interfaces/IList"; | ||||
|  | ||||
|   interface Props { | ||||
|     cast: Array<IMovie | IShow | IPerson | ICast | ICrew>; | ||||
|   } | ||||
|  | ||||
|   defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
|   .cast { | ||||
|     position: relative; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|  | ||||
|     ol { | ||||
|       overflow-x: scroll; | ||||
|       padding: 0; | ||||
|       list-style-type: none; | ||||
|       margin: 0; | ||||
|       display: flex; | ||||
|  | ||||
|       scrollbar-width: none; /* for Firefox */ | ||||
|  | ||||
|       &::-webkit-scrollbar { | ||||
|         display: none; /* for Chrome, Safari, and Opera */ | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										117
									
								
								src/components/CastListItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,117 @@ | ||||
| <template> | ||||
|   <li class="card"> | ||||
|     <a @click="openCastItem" @keydown.enter="openCastItem"> | ||||
|       <img :src="pictureUrl" alt="Movie or person poster image" /> | ||||
|       <p class="name">{{ creditItem.name || creditItem.title }}</p> | ||||
|       <p class="meta">{{ creditItem.character || creditItem.year }}</p> | ||||
|     </a> | ||||
|   </li> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, computed } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import type { ICast, ICrew, IMovie, IShow } from "../interfaces/IList"; | ||||
|  | ||||
|   interface Props { | ||||
|     creditItem: ICast | ICrew | IMovie | IShow; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const pictureUrl = computed(() => { | ||||
|     const baseUrl = "https://image.tmdb.org/t/p/w185"; | ||||
|  | ||||
|     if ("profile_path" in props.creditItem && props.creditItem.profile_path) { | ||||
|       return baseUrl + props.creditItem.profile_path; | ||||
|     } | ||||
|     if ("poster" in props.creditItem && props.creditItem.poster) { | ||||
|       return baseUrl + props.creditItem.poster; | ||||
|     } | ||||
|  | ||||
|     return "/assets/no-image_small.svg"; | ||||
|   }); | ||||
|  | ||||
|   function openCastItem() { | ||||
|     store.dispatch("popup/open", { ...props.creditItem }); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
|   li a p:first-of-type { | ||||
|     padding-top: 10px; | ||||
|   } | ||||
|  | ||||
|   li.card p { | ||||
|     font-size: 1em; | ||||
|     padding: 0 10px; | ||||
|     margin: 0; | ||||
|     overflow: hidden; | ||||
|     text-overflow: ellipsis; | ||||
|     max-height: calc(10px + ((16px * var(--line-height)) * 3)); | ||||
|   } | ||||
|  | ||||
|   li.card { | ||||
|     margin: 10px; | ||||
|     margin-right: 4px; | ||||
|     padding-bottom: 10px; | ||||
|     border-radius: 8px; | ||||
|     overflow: hidden; | ||||
|     cursor: pointer; | ||||
|  | ||||
|     min-width: 140px; | ||||
|     width: 140px; | ||||
|     background-color: var(--background-color-secondary); | ||||
|     color: var(--text-color); | ||||
|  | ||||
|     transition: all 0.3s ease; | ||||
|     transform: scale(0.97) translateZ(0); | ||||
|  | ||||
|     box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | ||||
|  | ||||
|     &:first-of-type { | ||||
|       margin-left: 0; | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|       box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); | ||||
|       transform: scale(1.03); | ||||
|     } | ||||
|  | ||||
|     .name { | ||||
|       font-weight: 500; | ||||
|     } | ||||
|  | ||||
|     .character { | ||||
|       font-size: 0.9em; | ||||
|     } | ||||
|  | ||||
|     .meta { | ||||
|       font-size: 0.9em; | ||||
|       color: var(--text-color-70); | ||||
|       display: -webkit-box; | ||||
|       overflow: hidden; | ||||
|       -webkit-line-clamp: 1; | ||||
|       -webkit-box-orient: vertical; | ||||
|       // margin-top: auto; | ||||
|       max-height: calc((0.9em * var(--line-height)) * 1); | ||||
|     } | ||||
|  | ||||
|     a { | ||||
|       display: block; | ||||
|       text-decoration: none; | ||||
|       height: 100%; | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|     } | ||||
|  | ||||
|     img { | ||||
|       width: 100%; | ||||
|       height: auto; | ||||
|       max-height: 210px; | ||||
|       background-color: var(--background-color); | ||||
|       object-fit: cover; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										170
									
								
								src/components/Graph.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,170 @@ | ||||
| <template> | ||||
|   <canvas ref="graphCanvas"></canvas> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps, onMounted, watch } from "vue"; | ||||
|   import { | ||||
|     Chart, | ||||
|     LineElement, | ||||
|     BarElement, | ||||
|     PointElement, | ||||
|     LineController, | ||||
|     BarController, | ||||
|     LinearScale, | ||||
|     CategoryScale, | ||||
|     Legend, | ||||
|     Title, | ||||
|     Tooltip, | ||||
|     ChartType | ||||
|   } from "chart.js"; | ||||
|  | ||||
|   import type { Ref } from "vue"; | ||||
|   import { convertSecondsToHumanReadable } from "../utils"; | ||||
|   import { GraphValueTypes } from "../interfaces/IGraph"; | ||||
|   import type { IGraphDataset, IGraphData } from "../interfaces/IGraph"; | ||||
|  | ||||
|   Chart.register( | ||||
|     LineElement, | ||||
|     BarElement, | ||||
|     PointElement, | ||||
|     LineController, | ||||
|     BarController, | ||||
|     LinearScale, | ||||
|     CategoryScale, | ||||
|     Legend, | ||||
|     Title, | ||||
|     Tooltip | ||||
|   ); | ||||
|  | ||||
|   interface Props { | ||||
|     name?: string; | ||||
|     data: IGraphData; | ||||
|     type: ChartType; | ||||
|     stacked: boolean; | ||||
|  | ||||
|     datasetDescriptionSuffix: string; | ||||
|     tooltipDescriptionSuffix: string; | ||||
|     graphValueType?: GraphValueTypes; | ||||
|   } | ||||
|  | ||||
|   Chart.defaults.elements.point.radius = 0; | ||||
|   Chart.defaults.elements.point.hitRadius = 10; | ||||
|   // Chart.defaults.elements.point.pointHoverRadius = 10; | ||||
|   Chart.defaults.elements.point.hoverBorderWidth = 4; | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const graphCanvas: Ref<HTMLCanvasElement> = ref(null); | ||||
|   let graphInstance = null; | ||||
|  | ||||
|   /* eslint-disable no-use-before-define */ | ||||
|   onMounted(() => generateGraph()); | ||||
|   watch(() => props.data, generateGraph); | ||||
|   /* eslint-enable no-use-before-define */ | ||||
|  | ||||
|   const graphTemplates = [ | ||||
|     { | ||||
|       backgroundColor: "rgba(54, 162, 235, 0.2)", | ||||
|       borderColor: "rgba(54, 162, 235, 1)", | ||||
|       borderWidth: 1, | ||||
|       tension: 0.4 | ||||
|     }, | ||||
|     { | ||||
|       backgroundColor: "rgba(255, 159, 64, 0.2)", | ||||
|       borderColor: "rgba(255, 159, 64, 1)", | ||||
|       borderWidth: 1, | ||||
|       tension: 0.4 | ||||
|     }, | ||||
|     { | ||||
|       backgroundColor: "rgba(255, 99, 132, 0.2)", | ||||
|       borderColor: "rgba(255, 99, 132, 1)", | ||||
|       borderWidth: 1, | ||||
|       tension: 0.4 | ||||
|     } | ||||
|   ]; | ||||
|   // const gridColor = getComputedStyle(document.documentElement).getPropertyValue( | ||||
|   //   "--text-color-5" | ||||
|   // ); | ||||
|  | ||||
|   function hydrateGraphLineOptions(dataset: IGraphDataset, index: number) { | ||||
|     return { | ||||
|       label: `${dataset.name} ${props.datasetDescriptionSuffix}`, | ||||
|       data: dataset.data, | ||||
|       ...graphTemplates[index] | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   function removeEmptyDataset(dataset: IGraphDataset) { | ||||
|     /* eslint-disable-next-line no-unneeded-ternary */ | ||||
|     return dataset.data.every(point => point === 0) ? false : true; | ||||
|   } | ||||
|  | ||||
|   function generateGraph() { | ||||
|     const datasets = props.data.series | ||||
|       .filter(removeEmptyDataset) | ||||
|       .map(hydrateGraphLineOptions); | ||||
|  | ||||
|     const graphOptions = { | ||||
|       maintainAspectRatio: false, | ||||
|       plugins: { | ||||
|         tooltip: { | ||||
|           callbacks: { | ||||
|             // title: (tooltipItem, data) => `Watch date: ${tooltipItem[0].label}`, | ||||
|             label: tooltipItem => { | ||||
|               const context = tooltipItem.dataset.label.split(" ")[0]; | ||||
|               const text = `${context} ${props.tooltipDescriptionSuffix}`; | ||||
|  | ||||
|               let value = tooltipItem.raw; | ||||
|               if (props.graphValueType === GraphValueTypes.Time) { | ||||
|                 value = convertSecondsToHumanReadable(value); | ||||
|               } | ||||
|  | ||||
|               return ` ${text}: ${value}`; | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|       scales: { | ||||
|         xAxes: { | ||||
|           stacked: props.stacked, | ||||
|           gridLines: { | ||||
|             display: false | ||||
|           } | ||||
|         }, | ||||
|         yAxes: { | ||||
|           stacked: props.stacked, | ||||
|           ticks: { | ||||
|             callback: value => { | ||||
|               if (props.graphValueType === GraphValueTypes.Time) { | ||||
|                 return convertSecondsToHumanReadable(value); | ||||
|               } | ||||
|  | ||||
|               return value; | ||||
|             }, | ||||
|             beginAtZero: true | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     const chartData = { | ||||
|       labels: props.data.labels.toString().split(","), | ||||
|       datasets | ||||
|     }; | ||||
|  | ||||
|     if (graphInstance) { | ||||
|       graphInstance.clear(); | ||||
|       graphInstance.data = chartData; | ||||
|       graphInstance.update("none"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     graphInstance = new Chart(graphCanvas.value, { | ||||
|       type: props.type, | ||||
|       data: chartData, | ||||
|       options: graphOptions | ||||
|     }); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped></style> | ||||
| @@ -1,85 +0,0 @@ | ||||
| <template> | ||||
|   <section> | ||||
|     <LandingBanner /> | ||||
|  | ||||
|     <div v-for="list in lists"> | ||||
|       <list-header :title="list.title" :link="'/list/' + list.route" /> | ||||
|  | ||||
|       <results-list :results="list.data" :shortList="true" /> | ||||
|       <loader v-if="!list.data.length" /> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import LandingBanner from '@/components/LandingBanner' | ||||
| import ListHeader from '@/components/ListHeader' | ||||
| import ResultsList from '@/components/ResultsList' | ||||
| import Loader from '@/components/ui/Loader' | ||||
|  | ||||
| import { getTmdbMovieListByName, getRequests } from '@/api' | ||||
|  | ||||
| export default { | ||||
|   name: 'home', | ||||
|   components: { LandingBanner, ResultsList, ListHeader, Loader }, | ||||
|   data(){ | ||||
|     return { | ||||
|       imageFile: 'dist/pulp-fiction.jpg', | ||||
|       requests: [], | ||||
|       nowplaying: [], | ||||
|       upcoming: [], | ||||
|       popular: [] | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     lists() { | ||||
|       return [ | ||||
|         { | ||||
|           title: 'Requests', | ||||
|           route: 'request', | ||||
|           data: this.requests | ||||
|         }, | ||||
|         { | ||||
|           title: 'Now playing', | ||||
|           route: 'now_playing', | ||||
|           data: this.nowplaying | ||||
|         }, | ||||
|         { | ||||
|           title: 'Upcoming', | ||||
|           route: 'upcoming', | ||||
|           data: this.upcoming | ||||
|         }, | ||||
|         { | ||||
|           title: 'Popular', | ||||
|           route: 'popular', | ||||
|           data: this.popular | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     fetchRequests() { | ||||
|       getRequests() | ||||
|         .then(results => this.requests = results.results) | ||||
|     }, | ||||
|     fetchNowPlaying() { | ||||
|       getTmdbMovieListByName('now_playing') | ||||
|         .then(results => this.nowplaying = results.results) | ||||
|     }, | ||||
|     fetchUpcoming() { | ||||
|       getTmdbMovieListByName('upcoming') | ||||
|         .then(results => this.upcoming = results.results) | ||||
|     }, | ||||
|     fetchPopular() { | ||||
|       getTmdbMovieListByName('popular') | ||||
|         .then(results => this.popular = results.results) | ||||
|     } | ||||
|   }, | ||||
|   created(){ | ||||
|     this.fetchRequests() | ||||
|     this.fetchNowPlaying() | ||||
|     this.fetchUpcoming() | ||||
|     this.fetchPopular() | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @@ -1,92 +1,218 @@ | ||||
| <template> | ||||
|   <header v-bind:style="{ 'background-image': 'url(' + imageFile + ')' }"> | ||||
|   <header ref="headerElement" :class="{ expanded, noselect: true }"> | ||||
|     <img ref="imageElement" :src="bannerImage" alt="Page banner image" /> | ||||
|     <div class="container"> | ||||
|       <h1 class="title">Request new movies or tv shows for plex</h1> | ||||
|       <strong class="subtitle">Made with Vue.js</strong> | ||||
|       <h1 class="title">Request movies or tv shows</h1> | ||||
|       <strong class="subtitle" | ||||
|         >Create a profile to track and view requests</strong | ||||
|       > | ||||
|     </div> | ||||
|  | ||||
|     <div | ||||
|       class="expand-icon" | ||||
|       @click="expand" | ||||
|       @keydown.enter="expand" | ||||
|       @mouseover="upgradeImage" | ||||
|       @focus="focus" | ||||
|     > | ||||
|       <IconExpand v-if="!expanded" /> | ||||
|       <IconShrink v-else /> | ||||
|     </div> | ||||
|   </header> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     image: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       imageFile: 'dist/pulp-fiction.jpg' | ||||
|     } | ||||
|   }, | ||||
|   beforeMount() { | ||||
|     if (this.image && this.image.length > 0) { | ||||
|       this.imageFile = this.image | ||||
| <script setup lang="ts"> | ||||
|   import { ref } from "vue"; | ||||
|   import IconExpand from "@/icons/IconExpand.vue"; | ||||
|   import IconShrink from "@/icons/IconShrink.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   const ASSET_URL = "https://request.movie/assets/"; | ||||
|   const images: Array<string> = [ | ||||
|     "pulp-fiction.jpg", | ||||
|     "arrival.jpg", | ||||
|     "disaster-artist.jpg", | ||||
|     "dune.jpg", | ||||
|     "mandalorian.jpg" | ||||
|   ]; | ||||
|  | ||||
|   const bannerImage: Ref<string> = ref(); | ||||
|   const expanded: Ref<boolean> = ref(false); | ||||
|   const headerElement: Ref<HTMLElement> = ref(null); | ||||
|   const imageElement: Ref<HTMLImageElement> = ref(null); | ||||
|   const defaultHeaderHeight: Ref<string> = ref(); | ||||
|   // const disableProxy = true; | ||||
|  | ||||
|   function expand() { | ||||
|     expanded.value = !expanded.value; | ||||
|     let height = defaultHeaderHeight?.value; | ||||
|  | ||||
|     if (expanded.value) { | ||||
|       const aspectRation = | ||||
|         imageElement.value.naturalHeight / imageElement.value.naturalWidth; | ||||
|       height = `${imageElement.value.clientWidth * aspectRation}px`; | ||||
|       defaultHeaderHeight.value = headerElement.value.style.height; | ||||
|     } | ||||
|  | ||||
|     headerElement.value.style.setProperty("--header-height", height); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   function focus(event: FocusEvent) { | ||||
|     event.preventDefault(); | ||||
|   } | ||||
|  | ||||
|   function randomImage(): string { | ||||
|     const image = images[Math.floor(Math.random() * images.length)]; | ||||
|     return ASSET_URL + image; | ||||
|   } | ||||
|  | ||||
|   bannerImage.value = randomImage(); | ||||
|  | ||||
|   // function sliceToHeaderSize(url: string): string { | ||||
|   //   let width = headerElement.value?.getBoundingClientRect()?.width || 1349; | ||||
|   //   let height = headerElement.value?.getBoundingClientRect()?.height || 261; | ||||
|  | ||||
|   //   if (disableProxy) return url; | ||||
|  | ||||
|   //   return buildProxyURL(width, height, url); | ||||
|   // } | ||||
|  | ||||
|   // function upgradeImage() { | ||||
|   //   if (disableProxy || imageUpgraded.value == true) return; | ||||
|  | ||||
|   //   const headerSize = 90; | ||||
|   //   const height = window.innerHeight - headerSize; | ||||
|   //   const width = window.innerWidth - headerSize; | ||||
|  | ||||
|   //   const proxyHost = `http://imgproxy.schleppe:8080/insecure/`; | ||||
|   //   const proxySizeOptions = `q:65/plain/`; | ||||
|  | ||||
|   //   bannerImage.value = `${proxyHost}${proxySizeOptions}${ | ||||
|   //     ASSET_URL + image.value | ||||
|   //   }`; | ||||
|   // } | ||||
|  | ||||
|   // function buildProxyURL(width: number, height: number, asset: string): string { | ||||
|   //   const proxyHost = `http://imgproxy.schleppe:8080/insecure/`; | ||||
|   //   const proxySizeOptions = `resize:fill:${width}:${height}:ce/q:65/plain/`; | ||||
|   //   return `${proxyHost}${proxySizeOptions}${asset}`; | ||||
|   // } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| header { | ||||
|   width: 100%; | ||||
|   height: 200px; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   background-size: cover; | ||||
|   background-repeat: no-repeat; | ||||
|   background-position: 50% 50%; | ||||
|   position: relative; | ||||
|  | ||||
|   @include tablet-min { | ||||
|     height: 284px; | ||||
|   } | ||||
|  | ||||
|   &:before { | ||||
|     content: ""; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|   header { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background-color: $background-70; | ||||
|     transition: background-color .5s ease; | ||||
|   } | ||||
|    | ||||
|   .container { | ||||
|     text-align: center; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     position: relative; | ||||
|     transition: color .5s ease; | ||||
|   } | ||||
|     transition: height 0.5s ease; | ||||
|     overflow: hidden; | ||||
|     --header-height: 25vh; | ||||
|  | ||||
|   .title { | ||||
|     font-weight: 500; | ||||
|     font-size: 22px; | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
|     color: $text-color; | ||||
|     margin: 0; | ||||
|     height: var(--header-height); | ||||
|  | ||||
|     @include tablet-min{ | ||||
|       font-size: 28px; | ||||
|     > * { | ||||
|       z-index: 1; | ||||
|     } | ||||
|  | ||||
|     img { | ||||
|       position: absolute; | ||||
|       z-index: 0; | ||||
|       object-fit: cover; | ||||
|       width: 100%; | ||||
|     } | ||||
|  | ||||
|     &.expanded { | ||||
|       // height: calc(100vh - var(--header-size)); | ||||
|       // width: calc(100vw - var(--header-size)); | ||||
|  | ||||
|       // @include mobile { | ||||
|       //   width: 100vw; | ||||
|       //   height: 100vh; | ||||
|       // } | ||||
|  | ||||
|       &:before { | ||||
|         background-color: transparent; | ||||
|       } | ||||
|  | ||||
|       .title, | ||||
|       .subtitle { | ||||
|         opacity: 0; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .expand-icon { | ||||
|       visibility: hidden; | ||||
|       opacity: 0; | ||||
|       transition: all 0.5s ease-in-out; | ||||
|       height: 1.8rem; | ||||
|       width: 1.8rem; | ||||
|       fill: var(--text-color-50); | ||||
|  | ||||
|       position: absolute; | ||||
|       top: 0.5rem; | ||||
|       right: 1rem; | ||||
|  | ||||
|       &:hover { | ||||
|         cursor: pointer; | ||||
|         fill: var(--text-color-90); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|       .expand-icon { | ||||
|         visibility: visible; | ||||
|         opacity: 1; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:before { | ||||
|       content: ""; | ||||
|       z-index: 1; | ||||
|       position: absolute; | ||||
|       top: 0; | ||||
|       left: 0; | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       background-color: var(--background-70); | ||||
|       transition: inherit; | ||||
|     } | ||||
|  | ||||
|     .container { | ||||
|       text-align: center; | ||||
|       position: relative; | ||||
|       transition: color 0.5s ease; | ||||
|     } | ||||
|  | ||||
|     .title { | ||||
|       font-weight: 500; | ||||
|       font-size: 22px; | ||||
|       text-transform: uppercase; | ||||
|       letter-spacing: 0.5px; | ||||
|       color: $text-color; | ||||
|       margin: 0; | ||||
|       opacity: 1; | ||||
|  | ||||
|       @include tablet-min { | ||||
|         font-size: 2.5rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .subtitle { | ||||
|       display: block; | ||||
|       font-size: 14px; | ||||
|       font-weight: 300; | ||||
|       color: $text-color-70; | ||||
|       margin: 5px 0; | ||||
|       opacity: 1; | ||||
|  | ||||
|       @include tablet-min { | ||||
|         font-size: 1.3rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .subtitle { | ||||
|     display: block; | ||||
|     font-size: 14px; | ||||
|     font-weight: 300; | ||||
|     color: $text-color-70; | ||||
|     margin: 5px 0; | ||||
|  | ||||
|     @include tablet-min{ | ||||
|       font-size: 16px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,101 +0,0 @@ | ||||
| <template> | ||||
|   <header :class="{ 'sticky': sticky }"> | ||||
|     <h2>{{ title }}</h2> | ||||
|  | ||||
|     <span v-if="info" class="result-count">{{ info }}</span> | ||||
|     <router-link v-else-if="link" :to="link" class='view-more'> | ||||
|       View All | ||||
|     </router-link> | ||||
|   </header>   | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     title: { | ||||
|       type: String, | ||||
|       required: true | ||||
|     }, | ||||
|     sticky: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false | ||||
|     }, | ||||
|     info: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     }, | ||||
|     link: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import './src/scss/variables'; | ||||
| @import './src/scss/media-queries'; | ||||
|  | ||||
| header { | ||||
|   width: 100%; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   padding: 1.8rem 12px; | ||||
|  | ||||
|   &.sticky { | ||||
|     background-color: $background-color; | ||||
|  | ||||
|     position: sticky; | ||||
|     position: -webkit-sticky; | ||||
|     top: $header-size; | ||||
|     z-index: 4; | ||||
|  | ||||
|     padding-bottom: 1rem; | ||||
|     margin-bottom: 1.5rem; | ||||
|   } | ||||
|  | ||||
|   h2 { | ||||
|     font-size: 18px; | ||||
|     font-weight: 300; | ||||
|     text-transform: capitalize; | ||||
|     line-height: 18px; | ||||
|     margin: 0; | ||||
|     color: $text-color; | ||||
|   } | ||||
|  | ||||
|   .view-more { | ||||
|     font-size: 13px; | ||||
|     font-weight: 300; | ||||
|     letter-spacing: .5px; | ||||
|     color: $text-color-70; | ||||
|     text-decoration: none; | ||||
|     transition: color .5s ease; | ||||
|     cursor: pointer; | ||||
|  | ||||
|     &:after{ | ||||
|       content: " →"; | ||||
|     } | ||||
|     &:hover{ | ||||
|       color: $text-color; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .result-count { | ||||
|     font-size: 13px; | ||||
|     font-weight: 300; | ||||
|     letter-spacing: .5px; | ||||
|     color: $text-color; | ||||
|     text-decoration: none; | ||||
|   } | ||||
|  | ||||
|   @include tablet-min { | ||||
|     padding-left: 1.25rem;; | ||||
|   } | ||||
|   @include desktop-lg-min { | ||||
|     padding-left: 1.75rem; | ||||
|   } | ||||
| } | ||||
|  | ||||
| </style> | ||||
| @@ -1,104 +0,0 @@ | ||||
|  | ||||
| <template> | ||||
|   <div> | ||||
|     <list-header :title="listTitle" :info="resultCount" :sticky="true" /> | ||||
|  | ||||
|     <results-list :results="results" v-if="results" /> | ||||
|  | ||||
|     <loader v-if="!results.length" /> | ||||
|  | ||||
|     <div v-if="page < totalPages" class="fullwidth-button"> | ||||
|       <seasoned-button @click="loadMore">load more</seasoned-button> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
|  | ||||
| <script> | ||||
| import ListHeader from '@/components/ListHeader' | ||||
| import ResultsList from '@/components/ResultsList' | ||||
| import SeasonedButton from '@/components/ui/SeasonedButton' | ||||
| import Loader from '@/components/ui/Loader' | ||||
| import { getTmdbMovieListByName, getRequests } from '@/api' | ||||
| import store from '@/store' | ||||
|  | ||||
| export default { | ||||
|   components: { ListHeader, ResultsList, SeasonedButton, Loader }, | ||||
|   data() { | ||||
|     return { | ||||
|       legalTmdbLists: [ 'now_playing', 'upcoming', 'popular' ], | ||||
|       results: [], | ||||
|       page: 1, | ||||
|       totalPages: 0, | ||||
|       totalResults: 0 | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     listTitle() { | ||||
|       if (this.results.length === 0) | ||||
|         return '' | ||||
|  | ||||
|       const routeListName = this.$route.params.name | ||||
|       console.log('routelistname', routeListName) | ||||
|       return routeListName.includes('_') ? routeListName.split('_').join(' ') : routeListName | ||||
|     }, | ||||
|     resultCount() { | ||||
|       if (this.results.length === 0) | ||||
|         return '' | ||||
|  | ||||
|       const loadedResults = this.results.length | ||||
|       const totalResults = this.totalResults < 10000 ? this.totalResults : '∞' | ||||
|       return `${loadedResults} of ${totalResults} results` | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     loadMore() { | ||||
|       console.log(this.$route) | ||||
|       this.page++ | ||||
|  | ||||
|       window.history.replaceState({}, 'search', `/#/${this.$route.fullPath}?page=${this.page}`) | ||||
|       this.init() | ||||
|     }, | ||||
|     init() { | ||||
|       const routeListName = this.$route.params.name | ||||
|  | ||||
|       if (routeListName === 'request') { | ||||
|         getRequests(this.page) | ||||
|           .then(results => { | ||||
|             this.results = this.results.concat(...results.results) | ||||
|             this.page = results.page | ||||
|             this.totalPages = results.total_pages | ||||
|             this.totalResults = results.total_results | ||||
|           }) | ||||
|       } else if (this.legalTmdbLists.includes(routeListName)) { | ||||
|         getTmdbMovieListByName(routeListName, this.page) | ||||
|           .then(results => { | ||||
|             this.results = this.results.concat(...results.results) | ||||
|             this.page = results.page | ||||
|             this.totalPages = results.total_pages | ||||
|             this.totalResults = results.total_results | ||||
|           }) | ||||
|       } else { | ||||
|         // TODO handle if list is not found | ||||
|         console.log('404 this is not a tmdb list') | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     if (this.results.length === 0) | ||||
|       this.init() | ||||
|  | ||||
|     store.dispatch('documentTitle/updateTitle', `${this.$router.history.current.name} ${this.$route.params.name}`) | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .fullwidth-button { | ||||
|   width: 100%; | ||||
|   margin: 1rem 0; | ||||
|   padding-bottom: 2rem; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
| } | ||||
| </style> | ||||
| @@ -1,420 +0,0 @@ | ||||
| <template> | ||||
|   <section class="movie"> | ||||
|  | ||||
|     <!-- HEADER w/ POSTER --> | ||||
|     <header class="movie__header" :style="{ 'background-image': movie && backdrop !== null ? 'url(' + ASSET_URL + ASSET_SIZES[1] + backdrop + ')' : '' }" :class="compact ? 'compact' : ''" @click="compact=!compact"> | ||||
|       <div class="movie__wrap movie__wrap--header"> | ||||
|         <figure class="movie__poster"> | ||||
|           <img v-if="movie && poster === null" | ||||
|             class="movies-item__img is-loaded" | ||||
|             alt="movie poster image" | ||||
|             src="~assets/no-image.png"> | ||||
|           <img v-else-if="poster === undefined" | ||||
|             class="movies-item__img grey" | ||||
|             alt="movie poster image"> | ||||
|             <!-- src="~assets/placeholder.png"> --> | ||||
|           <img v-else | ||||
|             class="movies-item__img is-loaded" | ||||
|             alt="movie poster image" | ||||
|             :src="ASSET_URL + ASSET_SIZES[0] + poster"> | ||||
|         </figure> | ||||
|  | ||||
|         <div class="movie__title"> | ||||
|           <h1 v-if="movie">{{ movie.title }}</h1> | ||||
|           <loading-placeholder v-else :count="1" /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </header> | ||||
|  | ||||
|     <!-- Siderbar and movie info --> | ||||
|     <div class="movie__main"> | ||||
|       <div class="movie__wrap movie__wrap--main"> | ||||
|  | ||||
|         <!-- SIDEBAR ACTIONS --> | ||||
|         <div class="movie__actions" v-if="movie"> | ||||
|  | ||||
|           <sidebar-list-element :iconRef="'#iconNot_exsits'" :active="matched" | ||||
|             :iconRefActive="'#iconExists'" :textActive="'Already in plex 🎉'"> | ||||
|  | ||||
|             Not yet in plex | ||||
|           </sidebar-list-element> | ||||
|           <sidebar-list-element @click="sendRequest" :iconRef="'#iconSent'" | ||||
|             :active="requested" :textActive="'Requested to be downloaded'"> | ||||
|  | ||||
|             Request to be downloaded? | ||||
|           </sidebar-list-element> | ||||
|           <sidebar-list-element v-if="admin" @click="showTorrents=!showTorrents" | ||||
|             :iconRef="'#icon_torrents'" :active="showTorrents" | ||||
|             :supplementaryText="numberOfTorrentResults"> | ||||
|  | ||||
|             Search for torrents | ||||
|           </sidebar-list-element> | ||||
|           <sidebar-list-element @click="openTmdb" :iconRef="'#icon_info'"> | ||||
|             See more info | ||||
|           </sidebar-list-element> | ||||
|         </div> | ||||
|  | ||||
|         <!-- Loading placeholder --> | ||||
|         <div class="movie__actions text-input__loading" v-else> | ||||
|           <div class="movie__actions-link" v-for="_ in admin ? Array(4) : Array(3)"> | ||||
|             <div class="movie__actions-text text-input__loading--line" style="margin:9px; margin-left: -3px;"></div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|  | ||||
|         <!-- MOVIE INFO --> | ||||
|         <div class="movie__info"> | ||||
|           <div class="movie__description" v-if="movie"> {{ movie.overview }}</div> | ||||
|  | ||||
|           <!-- Loading placeholder --> | ||||
|           <div v-else class="movie__description"> | ||||
|             <loading-placeholder :count="12" /> | ||||
|           </div> | ||||
|  | ||||
|           <div class="movie__details" v-if="movie"> | ||||
|             <div v-if="movie.year" class="movie__details-block"> | ||||
|               <h2 class="movie__details-title">Release Date</h2> | ||||
|               <div class="movie__details-text">{{ movie.year }}</div> | ||||
|             </div> | ||||
|  | ||||
|              <div v-if="movie.rank" class="movie__details-block"> | ||||
|               <h2 class="movie__details-title">Rating</h2> | ||||
|               <div class="movie__details-text">{{ movie.rank }}</div> | ||||
|             </div> | ||||
|  | ||||
|             <div v-if="movie.type == 'show'" class="movie__details-block"> | ||||
|               <h2 class="movie__details-title">Seasons</h2> | ||||
|               <div class="movie__details-text">{{ movie.seasons }}</div> | ||||
|             </div> | ||||
|  | ||||
|             <div v-if="movie.genres" class="movie__details-block"> | ||||
|               <h2 class="movie__details-title">Genres</h2> | ||||
|               <div class="movie__details-text">{{ nestedDataToString(movie.genres) }}</div> | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|         </div> | ||||
|  | ||||
|         <!-- TODO: change this classname, this is general  --> | ||||
|  | ||||
|         <div class="movie__admin" v-if="movie && movie.credits"> | ||||
|           <h2 class="movie__details-title">Cast</h2> | ||||
|           <div style="display: flex; flex-wrap: wrap;"> | ||||
|             <person v-for="cast in movie.credits.cast" :info="cast" | ||||
|               style="flex-basis: 0;"></person> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|       </div> | ||||
|  | ||||
|       <!-- TORRENT LIST --> | ||||
|       <TorrentList v-if="movie" :show="showTorrents" :query="title" :tmdb_id="id" | ||||
|                    :admin="admin"></TorrentList> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import storage from '@/storage' | ||||
| import img from '@/directives/v-image' | ||||
| import TorrentList from './TorrentList' | ||||
| import Person from './Person' | ||||
| import SidebarListElement from './ui/sidebarListElem' | ||||
| import store from '@/store' | ||||
| import LoadingPlaceholder from './ui/LoadingPlaceholder' | ||||
|  | ||||
| import { getMovie, getShow, request, getRequestStatus } from '@/api' | ||||
|  | ||||
| export default { | ||||
|   props: ['id', 'type'], | ||||
|   components: { TorrentList, Person, LoadingPlaceholder, SidebarListElement }, | ||||
|   directives: { img: img }, // TODO decide to remove or use | ||||
|   data(){ | ||||
|     return{ | ||||
|       ASSET_URL: 'https://image.tmdb.org/t/p/', | ||||
|       ASSET_SIZES: ['w500', 'w780', 'original'], | ||||
|       movie: undefined, | ||||
|       title: undefined, | ||||
|       poster: undefined, | ||||
|       backdrop: undefined, | ||||
|       matched: false, | ||||
|       userLoggedIn: storage.sessionId ? true : false, | ||||
|       requested: false, | ||||
|       admin: localStorage.getItem('admin'), | ||||
|       showTorrents: false, | ||||
|       compact: false | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     parseResponse(movie) { | ||||
|       this.movie = { ...movie } | ||||
|       this.title = movie.title | ||||
|       this.poster = movie.poster | ||||
|       this.backdrop = movie.backdrop | ||||
|       this.matched = movie.existsInPlex | ||||
|       this.checkIfRequested(movie) | ||||
|         .then(status => this.requested = status) | ||||
|  | ||||
|       store.dispatch('documentTitle/updateTitle', movie.title) | ||||
|     }, | ||||
|     async checkIfRequested(movie) { | ||||
|       return await getRequestStatus(movie.id, movie.type) | ||||
|     }, | ||||
|     nestedDataToString(data) { | ||||
|       let nestedArray = [] | ||||
|       data.forEach(item => nestedArray.push(item)); | ||||
|       return nestedArray.join(', '); | ||||
|     }, | ||||
|     sendRequest(){ | ||||
|       request(this.id, this.type, storage.token) | ||||
|         .then(resp => { | ||||
|           if (resp.success) { | ||||
|             this.requested = true | ||||
|           } | ||||
|         }) | ||||
|     }, | ||||
|     openTmdb(){ | ||||
|       const tmdbType = this.type === 'show' ? 'tv' : this.type | ||||
|       window.location.href = 'https://www.themoviedb.org/' + tmdbType + '/' + this.id | ||||
|     }, | ||||
|   }, | ||||
|   watch: { | ||||
|     id: function(val){ | ||||
|       if (this.type === 'movie') { | ||||
|         this.fetchMovie(val); | ||||
|       } else { | ||||
|         this.fetchShow(val) | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     numberOfTorrentResults: () => { | ||||
|       let numTorrents = store.getters['torrentModule/resultCount'] | ||||
|       return numTorrents !== null ? numTorrents + ' results' : null | ||||
|     } | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     store.dispatch('documentTitle/updateTitle', this.prevDocumentTitle) | ||||
|   }, | ||||
|   created(){ | ||||
|     this.prevDocumentTitle = store.getters['documentTitle/title'] | ||||
|  | ||||
|     if (this.type === 'movie') { | ||||
|       getMovie(this.id) | ||||
|         .then(this.parseResponse) | ||||
|         .catch(error => { | ||||
|           this.$router.push({ name: '404' }); | ||||
|         }) | ||||
|     } else { | ||||
|       getShow(this.id) | ||||
|         .then(this.parseResponse) | ||||
|         .catch(error => { | ||||
|           this.$router.push({ name: '404' }); | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     console.log('admin: ', this.admin) | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/loading-placeholder"; | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|  | ||||
| .movie { | ||||
|   &__wrap { | ||||
|     display: flex; | ||||
|     &--header { | ||||
|       align-items: center; | ||||
|       height: 100%; | ||||
|     } | ||||
|     &--main { | ||||
|       display: flex; | ||||
|       flex-wrap: wrap; | ||||
|       flex-direction: column; | ||||
|       @include tablet-min{ | ||||
|         flex-direction: row; | ||||
|       } | ||||
|  | ||||
|       background-color: $background-color; | ||||
|       color: $text-color; | ||||
|     } | ||||
|   } | ||||
|   &__header { | ||||
|     $duration: 0.2s; | ||||
|     height: 250px; | ||||
|     transform: scaleY(1); | ||||
|     transition: height $duration ease; | ||||
|     transform-origin: top; | ||||
|     position: relative; | ||||
|     background-size: cover; | ||||
|     background-repeat: no-repeat; | ||||
|     background-position: 50% 50%; | ||||
|     background-color: $background-color; | ||||
|     @include tablet-min { | ||||
|       height: 350px; | ||||
|     } | ||||
|     &:before { | ||||
|       content: ""; | ||||
|       display: block; | ||||
|       position: absolute; | ||||
|       top: 0; | ||||
|       left: 0; | ||||
|       z-index: 0; | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       background: $background-dark-85; | ||||
|     } | ||||
|     &.compact { | ||||
|       height: 100px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__poster { | ||||
|     display: none; | ||||
|     @include tablet-min { | ||||
|       background: $background-color; | ||||
|       height: 0; | ||||
|       display: block; | ||||
|       position: absolute; | ||||
|       width: calc(45% - 40px); | ||||
|       top: 40px; | ||||
|       left: 40px; | ||||
|  | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__img { | ||||
|     display: block; | ||||
|     width: 100%; | ||||
|     opacity: 0; | ||||
|     transform: scale(0.97) translateZ(0); | ||||
|     transition: opacity 0.5s ease, transform 0.5s ease; | ||||
|  | ||||
|     &.is-loaded { | ||||
|       opacity: 1; | ||||
|       transform: scale(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__title { | ||||
|     position: relative; | ||||
|     padding: 20px; | ||||
|     color: $green; | ||||
|     text-align: center; | ||||
|     width: 100%; | ||||
|     @include tablet-min { | ||||
|       width: 55%; | ||||
|       text-align: left; | ||||
|       margin-left: 45%; | ||||
|       padding: 30px 30px 30px 40px; | ||||
|     } | ||||
|     h1 { | ||||
|       font-weight: 500; | ||||
|       line-height: 1.4; | ||||
|       font-size: 24px; | ||||
|       @include tablet-min { | ||||
|         font-size: 30px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   &__main { | ||||
|     min-height: calc(100vh - 250px); | ||||
|     @include tablet-min { | ||||
|       min-height: 0; | ||||
|     } | ||||
|  | ||||
|     height: 100%; | ||||
|   } | ||||
|     &__actions { | ||||
|       text-align: center; | ||||
|       width: 100%; | ||||
|       order: 2; | ||||
|       padding: 20px; | ||||
|       border-top: 1px solid $text-color-5; | ||||
|       @include tablet-min { | ||||
|         order: 1; | ||||
|         width: 45%; | ||||
|         padding: 185px 0 40px 40px; | ||||
|         border-top: 0; | ||||
|       } | ||||
|     } | ||||
|     &__info { | ||||
|       width: 100%; | ||||
|       padding: 20px; | ||||
|       order: 1; | ||||
|       @include tablet-min { | ||||
|         order: 2; | ||||
|         padding: 40px; | ||||
|         width: 55%; | ||||
|         margin-left: 45%; | ||||
|       } | ||||
|     } | ||||
|     &__info { | ||||
|       margin-left: 0; | ||||
|     } | ||||
|     &__description { | ||||
|       font-weight: 300; | ||||
|       font-size: 13px; | ||||
|       line-height: 1.8; | ||||
|       margin-bottom: 20px; | ||||
|       @include tablet-min { | ||||
|         margin-bottom: 30px; | ||||
|         font-size: 14px; | ||||
|       } | ||||
|     } | ||||
|     &__details { | ||||
|       &-block { | ||||
|         float: left; | ||||
|       } | ||||
|       &-block:not(:last-child) { | ||||
|         margin-bottom: 20px; | ||||
|         margin-right: 20px; | ||||
|         @include tablet-min { | ||||
|           margin-bottom: 30px; | ||||
|           margin-right: 30px; | ||||
|         } | ||||
|       } | ||||
|       &-title { | ||||
|         margin: 0; | ||||
|         font-weight: 400; | ||||
|         text-transform: uppercase; | ||||
|         font-size: 14px; | ||||
|         color: $green; | ||||
|         @include tablet-min { | ||||
|           font-size: 16px; | ||||
|         } | ||||
|       } | ||||
|       &-text { | ||||
|         font-weight: 300; | ||||
|         font-size: 14px; | ||||
|         margin-top: 5px; | ||||
|       } | ||||
|     } | ||||
|     &__admin { | ||||
|       width: 100%; | ||||
|       padding: 20px; | ||||
|       order: 2; | ||||
|       @include tablet-min { | ||||
|         order: 3; | ||||
|         padding: 40px; | ||||
|         padding-top: 0px; | ||||
|         width: 100%; | ||||
|       } | ||||
|       &-title { | ||||
|           margin: 0; | ||||
|           font-weight: 400; | ||||
|           text-transform: uppercase; | ||||
|           text-align: center; | ||||
|           font-size: 14px; | ||||
|           color: $green; | ||||
|           padding-bottom: 20px; | ||||
|           @include tablet-min { | ||||
|             font-size: 16px; | ||||
|           } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| </style> | ||||
| @@ -1,12 +0,0 @@ | ||||
| <template> | ||||
|   <div class="container info"> | ||||
|     <movie :id="$route.params.id" :type="'page'"></movie> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import Movie from './Movie'; | ||||
| export default { | ||||
|   components: { Movie } | ||||
| } | ||||
| </script> | ||||
| @@ -1,102 +0,0 @@ | ||||
| <template> | ||||
|   <div class="movie-popup" @click="$popup.close()"> | ||||
|     <div class="movie-popup__box" @click.stop> | ||||
|       <movie :id="id" :type="type"></movie> | ||||
|       <button class="movie-popup__close" @click="$popup.close()"></button> | ||||
|     </div> | ||||
|     <i class="loader"></i> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import Movie from './Movie'; | ||||
|  | ||||
| export default { | ||||
|   props: { | ||||
|     id: { | ||||
|       type: Number, | ||||
|       required: true | ||||
|     }, | ||||
|     type: { | ||||
|       type: String, | ||||
|       required: true | ||||
|     } | ||||
|   }, | ||||
|   components: { Movie }, | ||||
|   methods: { | ||||
|     checkEventForEscapeKey(event) { | ||||
|       if (event.keyCode == 27) { | ||||
|         this.$popup.close() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   created(){ | ||||
|     window.addEventListener('keyup', this.checkEventForEscapeKey) | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     window.removeEventListener('keyup', this.checkEventForEscapeKey) | ||||
|   } | ||||
|  | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|  | ||||
| .movie-popup{ | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   z-index: 20; | ||||
|   width: 100%; | ||||
|   height: 100vh; | ||||
|   background: rgba($dark, 0.93); | ||||
|   -webkit-overflow-scrolling: touch; | ||||
|   overflow: auto; | ||||
|   &__box{ | ||||
|     width: 100%; | ||||
|     max-width: 768px; | ||||
|     position: relative; | ||||
|     z-index: 5; | ||||
|     background: $background-color-secondary; | ||||
|     padding-bottom: 50px; | ||||
|     @include tablet-min{ | ||||
|       padding-bottom: 0; | ||||
|       margin: 40px auto; | ||||
|     } | ||||
|   } | ||||
|   &__close{ | ||||
|     display: block; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     right: 0; | ||||
|     border: 0; | ||||
|     background: transparent; | ||||
|     width: 40px; | ||||
|     height: 40px; | ||||
|     transition: background 0.5s ease; | ||||
|     cursor: pointer; | ||||
|     &:before, | ||||
|     &:after{ | ||||
|       content: ""; | ||||
|       display: block; | ||||
|       position: absolute; | ||||
|       top: 19px; | ||||
|       left: 10px; | ||||
|       width: 20px; | ||||
|       height: 2px; | ||||
|       background: $white; | ||||
|     } | ||||
|     &:before{ | ||||
|       transform: rotate(45deg); | ||||
|     } | ||||
|     &:after{ | ||||
|       transform: rotate(-45deg); | ||||
|     } | ||||
|     &:hover{ | ||||
|       background: $green; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,117 +0,0 @@ | ||||
| <template> | ||||
|   <li class="movies-item" :class="{'shortList': shortList}"> | ||||
|     <a class="movies-item__link" :class="{'no-image': noImage}" @click.prevent="openMoviePopup(movie.id, movie.type)"> | ||||
|  | ||||
|       <!-- TODO change to picture element --> | ||||
|       <figure class="movies-item__poster"> | ||||
|         <img v-if="!noImage" class="movies-item__img" src="~assets/placeholder.png" v-img="poster()" alt=""> | ||||
|         <img v-if="noImage" class="movies-item__img is-loaded" src="~assets/no-image.png" alt=""> | ||||
|       </figure> | ||||
|       <div class="movies-item__content"> | ||||
|         <p class="movies-item__title">{{ movie.title }}</p> | ||||
|         <p class="movies-item__title">{{ movie.year }}</p> | ||||
|       </div> | ||||
|     </a> | ||||
|   </li> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import img from '../directives/v-image' | ||||
|  | ||||
| export default { | ||||
|   props: ['movie', 'shortList'], | ||||
|   directives: { | ||||
|     img: img | ||||
|   }, | ||||
|   data(){ | ||||
|     return { | ||||
|       noImage: false | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     // TODO handle missing images better and load diff sizes based on screen size | ||||
|     poster() { | ||||
|       if (this.movie.poster) { | ||||
|         return 'https://image.tmdb.org/t/p/w500' + this.movie.poster | ||||
|       } else { | ||||
|         this.noImage = true | ||||
|       } | ||||
|     }, | ||||
|     openMoviePopup(id, type) { | ||||
|       this.$popup.open(id, type) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|  | ||||
| .movies-item { | ||||
|   padding: 10px; | ||||
|   width: 50%; | ||||
|   background-color: $background-color; | ||||
|   transition: background-color 0.5s ease; | ||||
|  | ||||
|   @include tablet-min{ | ||||
|     padding: 15px; | ||||
|   } | ||||
|   @include tablet-landscape-min{ | ||||
|     padding: 15px; | ||||
|     width: 25%; | ||||
|   } | ||||
|   @include desktop-min{ | ||||
|     padding: 15px; | ||||
|     width: 20%; | ||||
|   } | ||||
|  | ||||
|   @include desktop-lg-min{ | ||||
|     padding: 20px; | ||||
|     width: 12.5%; | ||||
|   } | ||||
|  | ||||
|   &__link{ | ||||
|     text-decoration: none; | ||||
|     color: $text-color-70; | ||||
|     font-weight: 300; | ||||
|   } | ||||
|   &__content{ | ||||
|     padding-top: 15px; | ||||
|   } | ||||
|   &__poster{ | ||||
|     transition: transform 0.5s ease, box-shadow 0.3s ease; | ||||
|     transform: translateZ(0); | ||||
|   } | ||||
|   &__img{ | ||||
|     width: 100%; | ||||
|     opacity: 0; | ||||
|     transform: scale(0.97) translateZ(0); | ||||
|     transition: opacity 0.5s ease, transform 0.5s ease; | ||||
|     &.is-loaded{ | ||||
|       opacity: 1; | ||||
|       transform: scale(1); | ||||
|     } | ||||
|   } | ||||
|   &__link:not(.no-image):hover &__poster{ | ||||
|     transform: scale(1.03); | ||||
|     box-shadow: 0 0 10px rgba($dark, 0.1); | ||||
|   } | ||||
|   &__title{ | ||||
|     margin: 0; | ||||
|     font-size: 11px; | ||||
|     letter-spacing: 0.5px; | ||||
|     transition: color 0.5s ease; | ||||
|     cursor: pointer; | ||||
|     @include mobile-ls-min{ | ||||
|       font-size: 12px; | ||||
|     } | ||||
|     @include tablet-min{ | ||||
|       font-size: 14px; | ||||
|     } | ||||
|   } | ||||
|   &__link:hover &__title{ | ||||
|     color: $text-color; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,314 +0,0 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <nav class="nav"> | ||||
|       <router-link class="nav__logo" :to="{name: 'home'}" exact title="Vue.js — TMDb App"> | ||||
|         <svg class="nav__logo-image"> | ||||
|           <use xlink:href="#svgLogo"></use> | ||||
|         </svg> | ||||
|       </router-link> | ||||
|  | ||||
|       <div class="nav__hamburger" @click="toggleNav"> | ||||
|         <div v-for="_ in 3" class="bar"></div> | ||||
|       </div> | ||||
|  | ||||
|       <ul class="nav__list"> | ||||
|         <li class="nav__item" v-for="item in listTypes"> | ||||
|           <router-link class="nav__link" :to="'/list/' + item.route"> | ||||
|             <div class="nav__link-wrap"> | ||||
|               <svg class="nav__link-icon"> | ||||
|                 <use :xlink:href="'#icon_' + item.route"></use> | ||||
|               </svg> | ||||
|               <span class="nav__link-title">{{ item.title }}</span> | ||||
|             </div> | ||||
|           </router-link> | ||||
|         </li> | ||||
|  | ||||
|         <li class="nav__item nav__item--profile"> | ||||
|           <router-link class="nav__link nav__link--profile" :to="{name: 'signin'}" v-if="!userLoggedIn"> | ||||
|             <div class="nav__link-wrap"> | ||||
|               <svg class="nav__link-icon"> | ||||
|                 <use xlink:href="#iconLogin"></use> | ||||
|               </svg> | ||||
|               <span class="nav__link-title">Sign in</span> | ||||
|             </div> | ||||
|           </router-link> | ||||
|  | ||||
|           <router-link class="nav__link nav__link--profile" :to="{name: 'profile'}" v-if="userLoggedIn"> | ||||
|             <div class="nav__link-wrap"> | ||||
|               <svg class="nav__link-icon"> | ||||
|                 <use xlink:href="#iconLogin"></use> | ||||
|               </svg> | ||||
|               <span class="nav__link-title">Profile</span> | ||||
|             </div> | ||||
|           </router-link> | ||||
|         </li> | ||||
|       </ul> | ||||
|     </nav> | ||||
|  | ||||
|     <div class="spacer"></div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import storage from '@/storage' | ||||
|  | ||||
| export default { | ||||
|   data(){ | ||||
|     return { | ||||
|       listTypes: storage.homepageLists, | ||||
|       userLoggedIn: localStorage.getItem('token') ? true : false | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     setUserStatus(){ | ||||
|       this.userLoggedIn = localStorage.getItem('token') ? true : false; | ||||
|     }, | ||||
|     toggleNav(){ | ||||
|       document.querySelector('.nav__hamburger').classList.toggle('nav__hamburger--active'); | ||||
|       document.querySelector('.nav__list').classList.toggle('nav__list--active'); | ||||
|     } | ||||
|   }, | ||||
|   created(){ | ||||
|     // TODO move this to state manager | ||||
|     eventHub.$on('setUserStatus', this.setUserStatus); | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|  | ||||
| .icon { | ||||
|   width: 30px; | ||||
| } | ||||
|  | ||||
| .spacer { | ||||
|   @include mobile-only { | ||||
|     width: 100%; | ||||
|     height: $header-size; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .nav { | ||||
|   transition: background .5s ease; | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   height: 50px; | ||||
|   z-index: 10; | ||||
|   display: block; | ||||
|   color: $text-color; | ||||
|   background-color: $background-color-secondary; | ||||
|  | ||||
|   @include tablet-min{ | ||||
|     width: 95px; | ||||
|     height: 100vh; | ||||
|   } | ||||
|   &__logo { | ||||
|     width: 55px; | ||||
|     height: $header-size; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     background: $background-nav-logo; | ||||
|     @include tablet-min{ | ||||
|       width: 95px; | ||||
|     } | ||||
|     &-image{ | ||||
|       width: 35px; | ||||
|       height: 31px; | ||||
|       fill: $green; | ||||
|       transition: transform 0.5s ease; | ||||
|       @include tablet-min{ | ||||
|         width: 45px; | ||||
|         height: 40px; | ||||
|       } | ||||
|     } | ||||
|     &:hover &-image { | ||||
|       transform: scale(1.04); | ||||
|     } | ||||
|   } | ||||
|   &__hamburger { | ||||
|     display: block; | ||||
|     position: fixed; | ||||
|     width: 55px; | ||||
|     height: 50px; | ||||
|     top: 0; | ||||
|     right: 0; | ||||
|     cursor: pointer; | ||||
|     z-index: 10; | ||||
|     border-left: 1px solid $background-color; | ||||
|     @include tablet-min{ | ||||
|       display: none; | ||||
|     } | ||||
|     .bar { | ||||
|       position: absolute; | ||||
|       width: 23px; | ||||
|       height: 1px; | ||||
|       background-color: $text-color-70; | ||||
|       transition: all 300ms ease; | ||||
|       &:nth-child(1) { | ||||
|         left: 16px; | ||||
|         top: 17px; | ||||
|       } | ||||
|       &:nth-child(2) { | ||||
|         left: 16px; | ||||
|         top: 25px; | ||||
|         &:after { | ||||
|           content: ""; | ||||
|           position: absolute; | ||||
|           left: 0px; | ||||
|           top: 0px; | ||||
|           width: 23px; | ||||
|           height: 1px; | ||||
|           transition: all 300ms ease; | ||||
|         } | ||||
|       } | ||||
|       &:nth-child(3) { | ||||
|         right: 15px; | ||||
|         top: 33px; | ||||
|       } | ||||
|     } | ||||
|     &--active { | ||||
|       .bar{ | ||||
|         &:nth-child(1), | ||||
|         &:nth-child(3){ | ||||
|           width: 0; | ||||
|         } | ||||
|         &:nth-child(2) { | ||||
|           transform: rotate(-45deg); | ||||
|         } | ||||
|         &:nth-child(2):after { | ||||
|           transform: rotate(-90deg); | ||||
|           // background: rgba($c-dark, 0.5); | ||||
|           background-color: $text-color-70; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   &__list { | ||||
|     list-style: none; | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
|     text-align: center; | ||||
|     width: 100%; | ||||
|     position: fixed; | ||||
|     left: 0; | ||||
|     top: 50px; | ||||
|     border-top: 1px solid $background-color; | ||||
|     @include mobile-only { | ||||
|       display: flex; | ||||
|       flex-wrap: wrap; | ||||
|       font-size: 0; | ||||
|       opacity: 0; | ||||
|       visibility: hidden; | ||||
|       background-color: $background-95; | ||||
|       text-align: left; | ||||
|       &--active{ | ||||
|         opacity: 1; | ||||
|         visibility: visible; | ||||
|       } | ||||
|     } | ||||
|     @include tablet-min { | ||||
|       display: flex; | ||||
|       position: relative; | ||||
|       display: block; | ||||
|       width: 100%; | ||||
|       border-top: 0; | ||||
|       top: 0; | ||||
|     } | ||||
|   } | ||||
|   &__item { | ||||
|     transition: background .5s ease, color .5s ease, border .5s ease; | ||||
|     background-color: $background-color-secondary; | ||||
|     color: $text-color-70; | ||||
|  | ||||
|     @include mobile-only { | ||||
|       flex: 0 0 50%; | ||||
|       text-align: center; | ||||
|       border-bottom: 1px solid $background-color; | ||||
|       &:nth-child(odd){ | ||||
|         border-right: 1px solid $background-color; | ||||
|  | ||||
|         &:last-child { | ||||
|           // flex: 0 0 100%; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     @include tablet-min { | ||||
|       width: 100%; | ||||
|       border-bottom: 1px solid $text-color-5; | ||||
|  | ||||
|       &--profile { | ||||
|         position: fixed; | ||||
|         right: 0; | ||||
|         top: 0; | ||||
|         width: $header-size; | ||||
|         height: $header-size; | ||||
|         border-bottom: 0; | ||||
|         border-left: 1px solid $background-color; | ||||
|       } | ||||
|     } | ||||
|     &:hover, .is-active { | ||||
|       color: $text-color; | ||||
|       background-color: $background-color; | ||||
|     } | ||||
|   } | ||||
|   &__link { | ||||
|     background-color: inherit; // a elements have a transparent background | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     font-size: 7px; | ||||
|     font-weight: 300; | ||||
|     text-decoration: none; | ||||
|     text-transform: uppercase; | ||||
|     letter-spacing: 0.5px; | ||||
|     position: relative; | ||||
|     cursor: pointer; | ||||
|     &-wrap { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       align-items: center; | ||||
|     } | ||||
|  | ||||
|     @include mobile-only { | ||||
|       font-size: 10px; | ||||
|       padding: 20px 0; | ||||
|     } | ||||
|     @include tablet-min { | ||||
|       width: 95px; | ||||
|       height: 95px; | ||||
|       font-size: 9px; | ||||
|       &--profile { | ||||
|         width: 75px; | ||||
|         height: 75px; | ||||
|         background-color: $background-color-secondary; | ||||
|       } | ||||
|     } | ||||
|     &-icon { | ||||
|       width: 20px; | ||||
|       height: 20px; | ||||
|       fill: $text-color-70; | ||||
|       @include tablet-min { | ||||
|         width: 20px; | ||||
|         height: 20px; | ||||
|         margin-bottom: 5px; | ||||
|       } | ||||
|  | ||||
|     } | ||||
|     &-title { | ||||
|       margin-top: 5px; | ||||
|       display: block; | ||||
|       width: 100%; | ||||
|     } | ||||
|     &:hover &-icon, &.is-active &-icon { | ||||
|       fill: $text-color; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										110
									
								
								src/components/PageHeader.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,110 @@ | ||||
| <template> | ||||
|   <header> | ||||
|     <h2>{{ prettify }}</h2> | ||||
|     <h3>{{ subtitle }}</h3> | ||||
|  | ||||
|     <router-link | ||||
|       v-if="shortList" | ||||
|       :to="urlify" | ||||
|       class="view-more" | ||||
|       :aria-label="`View all ${title}`" | ||||
|     > | ||||
|       View All | ||||
|     </router-link> | ||||
|  | ||||
|     <div v-else-if="info"> | ||||
|       <div v-if="info instanceof Array" class="flex flex-direction-column"> | ||||
|         <span v-for="item in info" :key="item" class="info">{{ item }}</span> | ||||
|       </div> | ||||
|  | ||||
|       <span v-else class="info">{{ info }}</span> | ||||
|     </div> | ||||
|   </header> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, computed } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     title: string; | ||||
|     subtitle?: string; | ||||
|     info?: string | Array<string>; | ||||
|     link?: string; | ||||
|     shortList?: boolean; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|  | ||||
|   const urlify = computed(() => { | ||||
|     return `/list/${props.title.toLowerCase().replace(" ", "_")}`; | ||||
|   }); | ||||
|  | ||||
|   const prettify = computed(() => { | ||||
|     return props.title.includes("_") | ||||
|       ? props.title.split("_").join(" ") | ||||
|       : props.title; | ||||
|   }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
|   header { | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     padding: 0.5rem 0.75rem; | ||||
|     background-color: $background-color; | ||||
|  | ||||
|     position: sticky; | ||||
|     position: -webkit-sticky; | ||||
|     top: $header-size; | ||||
|     z-index: 1; | ||||
|  | ||||
|     h2 { | ||||
|       font-size: 1.4rem; | ||||
|       font-weight: 300; | ||||
|       text-transform: capitalize; | ||||
|       line-height: 1.4rem; | ||||
|       margin: 0; | ||||
|       color: $text-color; | ||||
|     } | ||||
|  | ||||
|     .view-more { | ||||
|       font-size: 0.9rem; | ||||
|       font-weight: 300; | ||||
|       letter-spacing: 0.5px; | ||||
|       color: $text-color-70; | ||||
|       text-decoration: none; | ||||
|       transition: color 0.5s ease; | ||||
|       cursor: pointer; | ||||
|  | ||||
|       &:after { | ||||
|         content: " →"; | ||||
|       } | ||||
|       &:hover { | ||||
|         color: $text-color; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .info { | ||||
|       font-size: 13px; | ||||
|       font-weight: 300; | ||||
|       letter-spacing: 0.5px; | ||||
|       color: $text-color; | ||||
|       text-decoration: none; | ||||
|       text-align: right; | ||||
|     } | ||||
|  | ||||
|     @include tablet-min { | ||||
|       padding-left: 1.25rem; | ||||
|     } | ||||
|  | ||||
|     @include desktop-lg-min { | ||||
|       padding-left: 1.75rem; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
| @@ -1,63 +0,0 @@ | ||||
| <template> | ||||
|   <div class="persons"> | ||||
|     <div class="persons--image" :style="{  | ||||
|       'background-image': 'url(' + getPicture(info) + ')' }"></div> | ||||
|     <span>{{ info.name }}</span> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
|  | ||||
| export default { | ||||
|   name: 'Person', | ||||
|   components: { | ||||
|  | ||||
|   }, | ||||
|   props: { | ||||
|     info: Object | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|  | ||||
|     } | ||||
|   }, | ||||
|   created() {}, | ||||
|   beforeMount() {}, | ||||
|   computed: { | ||||
|      | ||||
|   }, | ||||
|   methods: { | ||||
| getPicture: (person) => { | ||||
|   if (person) | ||||
|       return 'https://image.tmdb.org/t/p/w185' + person.profile_path; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| .persons { | ||||
|   display: flex; | ||||
|   // border: 1px solid black; | ||||
|   flex-direction: column; | ||||
|  | ||||
|   margin: 0 0.5rem; | ||||
|  | ||||
|   span { | ||||
|     font-size: 0.6rem; | ||||
|   } | ||||
|  | ||||
|   &--image { | ||||
|     border-radius: 50%; | ||||
|     height: 70px; | ||||
|     width: 70px; | ||||
|     // height: auto; | ||||
|     background-size: cover; | ||||
|     background-repeat: no-repeat; | ||||
|     background-position: 50% 50%; | ||||
|   } | ||||
|  | ||||
|   &--name { | ||||
|   } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										152
									
								
								src/components/Popup.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,152 @@ | ||||
| <template> | ||||
|   <div v-if="isOpen" class="movie-popup" @click="close" @keydown.enter="close"> | ||||
|     <div class="movie-popup__box" @click.stop> | ||||
|       <person v-if="type === 'person'" :id="id" type="person" /> | ||||
|       <movie v-else :id="id" :type="type"></movie> | ||||
|       <button class="movie-popup__close" @click="close"></button> | ||||
|     </div> | ||||
|     <i class="loader"></i> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, onMounted, onBeforeUnmount } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import Movie from "@/components/popup/Movie.vue"; | ||||
|   import Person from "@/components/popup/Person.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { MediaTypes } from "../interfaces/IList"; | ||||
|  | ||||
|   interface URLQueryParameters { | ||||
|     id: number; | ||||
|     type: MediaTypes; | ||||
|   } | ||||
|  | ||||
|   const store = useStore(); | ||||
|   const isOpen: Ref<boolean> = ref(); | ||||
|   const id: Ref<string> = ref(); | ||||
|   const type: Ref<MediaTypes> = ref(); | ||||
|  | ||||
|   const unsubscribe = store.subscribe((mutation, state) => { | ||||
|     if (!mutation.type.includes("popup")) return; | ||||
|  | ||||
|     isOpen.value = state.popup.open; | ||||
|     id.value = state.popup.id; | ||||
|     type.value = state.popup.type; | ||||
|  | ||||
|     if (isOpen.value) { | ||||
|       document.getElementsByTagName("body")[0].classList.add("no-scroll"); | ||||
|     } else { | ||||
|       document.getElementsByTagName("body")[0].classList.remove("no-scroll"); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   function getFromURLQuery(): URLQueryParameters { | ||||
|     let _id: number; | ||||
|     let _type: MediaTypes; | ||||
|  | ||||
|     const params = new URLSearchParams(window.location.search); | ||||
|     params.forEach((value, key) => { | ||||
|       if ( | ||||
|         key !== MediaTypes.Movie && | ||||
|         key !== MediaTypes.Show && | ||||
|         key !== MediaTypes.Person | ||||
|       ) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       _id = Number(params.get(key)); | ||||
|       _type = key; | ||||
|     }); | ||||
|  | ||||
|     return { id: _id, type: _type }; | ||||
|   } | ||||
|  | ||||
|   function open(_id: number, _type: string) { | ||||
|     if (!_id || !_type) return; | ||||
|     store.dispatch("popup/open", { id: _id, type: _type }); | ||||
|   } | ||||
|  | ||||
|   function close() { | ||||
|     store.dispatch("popup/close"); | ||||
|   } | ||||
|  | ||||
|   function checkEventForEscapeKey(event: KeyboardEvent) { | ||||
|     if (event.keyCode !== 27) return; | ||||
|     close(); | ||||
|   } | ||||
|  | ||||
|   window.addEventListener("keyup", checkEventForEscapeKey); | ||||
|  | ||||
|   onMounted(() => { | ||||
|     const query = getFromURLQuery(); | ||||
|     open(query?.id, query?.type); | ||||
|   }); | ||||
|  | ||||
|   onBeforeUnmount(() => { | ||||
|     unsubscribe(); | ||||
|     window.removeEventListener("keyup", checkEventForEscapeKey); | ||||
|   }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
|   .movie-popup { | ||||
|     position: fixed; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     z-index: 20; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background: rgba($dark, 0.93); | ||||
|     -webkit-overflow-scrolling: touch; | ||||
|     overflow: auto; | ||||
|  | ||||
|     &__box { | ||||
|       max-width: 768px; | ||||
|       position: relative; | ||||
|       z-index: 5; | ||||
|       margin: 8vh auto; | ||||
|  | ||||
|       @include mobile { | ||||
|         margin: 0 0 50px 0; | ||||
|       } | ||||
|     } | ||||
|     &__close { | ||||
|       display: block; | ||||
|       position: absolute; | ||||
|       top: 0; | ||||
|       right: 0; | ||||
|       border: 0; | ||||
|       background: transparent; | ||||
|       width: 40px; | ||||
|       height: 40px; | ||||
|       transition: background 0.5s ease; | ||||
|       cursor: pointer; | ||||
|       z-index: 5; | ||||
|  | ||||
|       &:before, | ||||
|       &:after { | ||||
|         content: ""; | ||||
|         display: block; | ||||
|         position: absolute; | ||||
|         top: 19px; | ||||
|         left: 10px; | ||||
|         width: 20px; | ||||
|         height: 2px; | ||||
|         background: $white; | ||||
|       } | ||||
|       &:before { | ||||
|         transform: rotate(45deg); | ||||
|       } | ||||
|       &:after { | ||||
|         transform: rotate(-45deg); | ||||
|       } | ||||
|       &:hover { | ||||
|         background: $green; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
| @@ -1,161 +0,0 @@ | ||||
| <template> | ||||
|   <section class="profile"> | ||||
|     <div class="profile__content" v-if="userLoggedIn"> | ||||
|       <header class="profile__header"> | ||||
|         <h2 class="profile__title">{{ emoji }} Welcome {{ userName }}</h2> | ||||
|  | ||||
|         <div class="button--group"> | ||||
|           <seasoned-button @click="showSettings = !showSettings">{{ showSettings ? 'hide settings' : 'show settings' }}</seasoned-button> | ||||
|  | ||||
|           <seasoned-button @click="logOut">Log out</seasoned-button> | ||||
|         </div> | ||||
|       </header> | ||||
|  | ||||
|       <settings v-if="showSettings"></settings> | ||||
|  | ||||
|       <list-header title="User requests" :info="resultCount"/> | ||||
|       <results-list v-if="results" :results="results" /> | ||||
|     </div> | ||||
|  | ||||
|     <section class="not-found" v-if="!userLoggedIn"> | ||||
|       <div class="not-found__content"> | ||||
|         <h2 class="not-found__title">Authentication Request Failed</h2> | ||||
|         <router-link :to="{name: 'signin'}" exact title="Sign in here"> | ||||
|           <button class="not-found__button button">Sign In</button> | ||||
|         </router-link> | ||||
|       </div> | ||||
|     </section> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import storage from '@/storage' | ||||
| import store from '@/store' | ||||
| import ListHeader from '@/components/ListHeader' | ||||
| import ResultsList from '@/components/ResultsList' | ||||
| import Settings from '@/components/Settings' | ||||
| import SeasonedButton from '@/components/ui/SeasonedButton' | ||||
|  | ||||
| import { getEmoji, getUserRequests } from '@/api' | ||||
|  | ||||
| export default { | ||||
|   components: { ListHeader, ResultsList, Settings, SeasonedButton }, | ||||
|   data(){ | ||||
|     return{ | ||||
|       userLoggedIn: '', | ||||
|       userName: '', | ||||
|       emoji: '', | ||||
|       results: undefined, | ||||
|       totalResults: undefined, | ||||
|       showSettings: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     resultCount() { | ||||
|       if (this.results === undefined) | ||||
|         return | ||||
|  | ||||
|       const loadedResults = this.results.length | ||||
|       const totalResults = this.totalResults < 10000 ? this.totalResults : '∞' | ||||
|       return `${loadedResults} of ${totalResults} results` | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     createSession(token){ | ||||
|       axios.get(`https://api.themoviedb.org/3/authentication/session/new?api_key=${storage.apiKey}&request_token=${token}`) | ||||
|       .then(function(resp){ | ||||
|           let data = resp.data; | ||||
|           if(data.success){ | ||||
|             let id = data.session_id; | ||||
|             localStorage.setItem('session_id', id); | ||||
|             eventHub.$emit('setUserStatus'); | ||||
|             this.userLoggedIn = true; | ||||
|             this.getUserInfo(); | ||||
|           } | ||||
|       }.bind(this)); | ||||
|     }, | ||||
|     getUserInfo(){ | ||||
|       this.userName = localStorage.getItem('username');  | ||||
|     }, | ||||
|     toggleSettings() { | ||||
|       this.showSettings = this.showSettings ? false : true; | ||||
|     }, | ||||
|     logOut(){ | ||||
|       localStorage.clear(); | ||||
|       eventHub.$emit('setUserStatus'); | ||||
|       this.$router.push({ name: 'home' }); | ||||
|     } | ||||
|   }, | ||||
|   created(){ | ||||
|     if(!localStorage.getItem('token')){ | ||||
|       this.userLoggedIn = false; | ||||
|     } else { | ||||
|       this.userLoggedIn = true; | ||||
|       this.getUserInfo(); | ||||
|  | ||||
|       getUserRequests() | ||||
|         .then(results => { | ||||
|           this.results = results.results | ||||
|           this.totalResults = results.total_results | ||||
|         }) | ||||
|  | ||||
|       getEmoji() | ||||
|         .then(resp => { | ||||
|           const { emoji } = resp | ||||
|           this.emoji = emoji | ||||
|           store.dispatch('documentTitle/updateEmoji', emoji) | ||||
|         }) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|  | ||||
| .button--group { | ||||
|   display: flex; | ||||
| } | ||||
|  | ||||
| // DUPLICATE CODE | ||||
| .profile{ | ||||
|   &__header{ | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: space-between; | ||||
|     padding: 20px; | ||||
|     border-bottom: 1px solid $text-color-5; | ||||
|  | ||||
|     @include mobile-only { | ||||
|       flex-direction: column; | ||||
|       align-items: flex-start; | ||||
|  | ||||
|       .button--group { | ||||
|         padding-top: 2rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     @include tablet-min{ | ||||
|       padding: 29px 30px; | ||||
|     } | ||||
|     @include tablet-landscape-min{ | ||||
|       padding: 29px 50px; | ||||
|     } | ||||
|     @include desktop-min{ | ||||
|       padding: 29px 60px; | ||||
|     } | ||||
|   } | ||||
|   &__title{ | ||||
|     margin: 0; | ||||
|     font-size: 16px; | ||||
|     line-height: 16px; | ||||
|     color: $text-color; | ||||
|     font-weight: 300; | ||||
|     @include tablet-min{ | ||||
|       font-size: 18px; | ||||
|       line-height: 18px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,126 +0,0 @@ | ||||
| <template> | ||||
|   <section> | ||||
|     <h1>Register new user</h1> | ||||
|  | ||||
|     <seasoned-input placeholder="username" icon="Email" type="username" :value.sync="username" /> | ||||
|  | ||||
|     <seasoned-input placeholder="password" icon="Keyhole" type="password" | ||||
|       :value.sync="password" @enter="requestNewUser"/> | ||||
|  | ||||
|     <seasoned-input placeholder="repeat password" icon="Keyhole" type="password" | ||||
|       :value.sync="passwordRepeat" @enter="requestNewUser"/> | ||||
|  | ||||
|     <seasoned-button @click="requestNewUser">Register</seasoned-button> | ||||
|  | ||||
|     <router-link class="link" to="/signin">Have a user? Sign in here</router-link> | ||||
|     <seasoned-messages :messages.sync="messages"></seasoned-messages> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import axios from 'axios' | ||||
| import SeasonedButton from '@/components/ui/SeasonedButton' | ||||
| import SeasonedInput from '@/components/ui/SeasonedInput' | ||||
| import SeasonedMessages from '@/components/ui/SeasonedMessages' | ||||
|  | ||||
| export default { | ||||
|   components: { SeasonedButton, SeasonedInput, SeasonedMessages }, | ||||
|   data() { | ||||
|     return { | ||||
|       messages: [], | ||||
|       username: null, | ||||
|       password: null, | ||||
|       passwordRepeat: null | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     requestNewUser(){ | ||||
|       let { username, password, passwordRepeat } = this | ||||
|  | ||||
|       let verifyCredentials = this.checkCredentials(username, password, passwordRepeat); | ||||
|  | ||||
|       if (verifyCredentials.verified) { | ||||
|         axios.post(`https://api.kevinmidboe.com/api/v1/user`, { | ||||
|           username: username, | ||||
|           password: password | ||||
|         }) | ||||
|         .then(resp => { | ||||
|           let data = resp.data; | ||||
|           if (data.success){ | ||||
|             localStorage.setItem('token', data.token); | ||||
|             localStorage.setItem('username', username); | ||||
|             localStorage.setItem('admin', data.admin) | ||||
|              | ||||
|             eventHub.$emit('setUserStatus'); | ||||
|             this.$router.push({ name: 'profile' }) | ||||
|           } | ||||
|         }) | ||||
|         .catch(error => { | ||||
|           this.messages.push({ type: 'error', title: 'Unexpected error', message: error.response.data.error }) | ||||
|         }); | ||||
|       }  | ||||
|       else { | ||||
|         this.messages.push({ type: 'warning', title: 'Parse error', message: verifyCredentials.reason }) | ||||
|       } | ||||
|     }, | ||||
|     checkCredentials(username, password, passwordRepeat) { | ||||
|       if (!username || username.length === 0) { | ||||
|         return { | ||||
|           verified: false, | ||||
|           reason: 'Fill inn username' | ||||
|         } | ||||
|       }  | ||||
|       else if (!password || !passwordRepeat) { | ||||
|         return { | ||||
|           verified: false, | ||||
|           reason: "Fill inn both password fields" | ||||
|         } | ||||
|       } | ||||
|       else if (password !== passwordRepeat) { | ||||
|         return { | ||||
|           verified: false, | ||||
|           reason: 'Passwords do not match' | ||||
|         } | ||||
|       }  | ||||
|       else { | ||||
|         return { | ||||
|           verified: true, | ||||
|           reason: 'Verified credentials' | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     logOut(){ | ||||
|       localStorage.clear(); | ||||
|       eventHub.$emit('setUserStatus'); | ||||
|       this.$router.push({ name: 'home' }); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
|  | ||||
| section { | ||||
|   padding: 1.3rem; | ||||
|  | ||||
|   @include tablet-min { | ||||
|     padding: 4rem; | ||||
|   } | ||||
|  | ||||
|   h1 { | ||||
|     margin: 0; | ||||
|     line-height: 16px; | ||||
|     color: $text-color; | ||||
|     font-weight: 300; | ||||
|     margin-bottom: 20px; | ||||
|     text-transform: uppercase; | ||||
|   } | ||||
|  | ||||
|   .link { | ||||
|     display: block; | ||||
|     width: max-content; | ||||
|     margin-top: 1rem; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,68 +1,77 @@ | ||||
| <template> | ||||
|   <ul class="results" :class="{'shortList': shortList}"> | ||||
|     <movies-list-item v-for='movie in results' :movie="movie" /> | ||||
|   </ul> | ||||
|   <div> | ||||
|     <ul | ||||
|       v-if="results && results.length" | ||||
|       class="results" | ||||
|       :class="{ shortList: shortList }" | ||||
|     > | ||||
|       <results-list-item | ||||
|         v-for="(result, index) in results" | ||||
|         :key="generateResultKey(index, `${result.type}-${result.id}`)" | ||||
|         :list-item="result" | ||||
|       /> | ||||
|     </ul> | ||||
|  | ||||
|     <span v-else-if="!loading" class="no-results">No results found</span> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|   import ResultsListItem from "@/components/ResultsListItem.vue"; | ||||
|   import type { ListResults } from "../interfaces/IList"; | ||||
|  | ||||
| <script> | ||||
| import MoviesListItem from '@/components/MoviesListItem' | ||||
|  | ||||
| export default { | ||||
|   components: { MoviesListItem }, | ||||
|   props: { | ||||
|     results: { | ||||
|       type: Array, | ||||
|       required: true | ||||
|     }, | ||||
|     shortList: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: false | ||||
|     } | ||||
|   interface Props { | ||||
|     results: Array<ListResults>; | ||||
|     shortList?: boolean; | ||||
|     loading?: boolean; | ||||
|   } | ||||
|  | ||||
|   defineProps<Props>(); | ||||
|  | ||||
|   function generateResultKey(index: string | number | symbol, value: string) { | ||||
|     return `${String(index)}-${value}`; | ||||
|   } | ||||
| }   | ||||
| </script> | ||||
|  | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import './src/scss/media-queries'; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
| .results { | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   list-style: none; | ||||
|   .no-results { | ||||
|     width: 100%; | ||||
|     display: block; | ||||
|     text-align: center; | ||||
|     margin: 1.5rem; | ||||
|     font-size: 1.2rem; | ||||
|   } | ||||
|  | ||||
|   &.shortList > li { | ||||
|     display: none; | ||||
|   .results { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); | ||||
|     grid-auto-rows: auto; | ||||
|     margin: 0; | ||||
|     padding: 0; | ||||
|     list-style: none; | ||||
|  | ||||
|     &:nth-child(-n+4) { | ||||
|       display: block; | ||||
|     @include mobile { | ||||
|       grid-template-columns: repeat(2, 1fr); | ||||
|     } | ||||
|  | ||||
|     &.shortList { | ||||
|       overflow: auto; | ||||
|       grid-auto-flow: column; | ||||
|       max-width: 100vw; | ||||
|  | ||||
|       @include noscrollbar; | ||||
|  | ||||
|       > li { | ||||
|         min-width: 225px; | ||||
|       } | ||||
|  | ||||
|       @include tablet-min { | ||||
|         max-width: calc(100vw - var(--header-size)); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @include tablet-min { | ||||
|   .results.shortList > li:nth-child(-n+6) { | ||||
|     display: block; | ||||
|   } | ||||
| } | ||||
| @include tablet-landscape-min { | ||||
|   .results.shortList > li:nth-child(-n+8) { | ||||
|     display: block; | ||||
|   } | ||||
| } | ||||
| @include desktop-min { | ||||
|   .results.shortList > li:nth-child(-n+10) { | ||||
|     display: block; | ||||
|   } | ||||
| } | ||||
| @include desktop-lg-min { | ||||
|   .results.shortList > li:nth-child(-n+16) { | ||||
|     display: block; | ||||
|   } | ||||
| } | ||||
|  | ||||
| </style> | ||||
							
								
								
									
										183
									
								
								src/components/ResultsListItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,183 @@ | ||||
| <template> | ||||
|   <li ref="list-item" class="movie-item"> | ||||
|     <figure | ||||
|       ref="posterElement" | ||||
|       class="movie-item__poster" | ||||
|       @click="openMoviePopup" | ||||
|       @keydown.enter="openMoviePopup" | ||||
|     > | ||||
|       <img | ||||
|         class="movie-item__img" | ||||
|         :alt="posterAltText" | ||||
|         :data-src="poster" | ||||
|         src="/assets/placeholder.png" | ||||
|       /> | ||||
|  | ||||
|       <div v-if="listItem.download" class="progress"> | ||||
|         <progress :value="listItem.download.progress" max="100"></progress> | ||||
|         <span | ||||
|           >{{ listItem.download.state }}: | ||||
|           {{ listItem.download.progress }}%</span | ||||
|         > | ||||
|       </div> | ||||
|     </figure> | ||||
|  | ||||
|     <div class="movie-item__info"> | ||||
|       <p v-if="listItem.title || listItem.name" class="movie-item__title"> | ||||
|         {{ listItem.title || listItem.name }} | ||||
|       </p> | ||||
|       <p v-if="listItem.year">{{ listItem.year }}</p> | ||||
|       <p v-if="listItem.type == 'person'"> | ||||
|         Known for: {{ listItem.known_for_department }} | ||||
|       </p> | ||||
|     </div> | ||||
|   </li> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineProps, onMounted } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type { IMovie, IShow, IPerson } from "../interfaces/IList"; | ||||
|  | ||||
|   interface Props { | ||||
|     listItem: IMovie | IShow | IPerson; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500"; | ||||
|   const IMAGE_FALLBACK = "/assets/no-image.svg"; | ||||
|   const poster: Ref<string> = ref(); | ||||
|   const posterElement: Ref<HTMLElement> = ref(null); | ||||
|   const observed: Ref<boolean> = ref(false); | ||||
|  | ||||
|   if (props.listItem?.poster) { | ||||
|     poster.value = IMAGE_BASE_URL + props.listItem.poster; | ||||
|   } else { | ||||
|     poster.value = IMAGE_FALLBACK; | ||||
|   } | ||||
|  | ||||
|   const posterAltText = computed(() => { | ||||
|     const type = props.listItem.type || ""; | ||||
|     let title = ""; | ||||
|  | ||||
|     if ("name" in props.listItem) title = props.listItem.name; | ||||
|     else if ("title" in props.listItem) title = props.listItem.title; | ||||
|  | ||||
|     return props.listItem.poster | ||||
|       ? `Poster for ${type} ${title}` | ||||
|       : `Missing image for ${type} ${title}`; | ||||
|   }); | ||||
|  | ||||
|   function observePosterAndSetImageSource() { | ||||
|     const imageElement = posterElement.value.getElementsByTagName("img")[0]; | ||||
|     if (imageElement == null) return; | ||||
|  | ||||
|     const imageObserver = new IntersectionObserver(entries => { | ||||
|       entries.forEach(entry => { | ||||
|         if (entry.isIntersecting && observed.value === false) { | ||||
|           const lazyImage = entry.target as HTMLImageElement; | ||||
|           lazyImage.src = lazyImage.dataset.src; | ||||
|           posterElement.value.classList.add("is-loaded"); | ||||
|           observed.value = true; | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     imageObserver.observe(imageElement); | ||||
|   } | ||||
|  | ||||
|   onMounted(observePosterAndSetImageSource); | ||||
|  | ||||
|   function openMoviePopup() { | ||||
|     store.dispatch("popup/open", { ...props.listItem }); | ||||
|   } | ||||
|  | ||||
|   // const imageSize = computed(() => { | ||||
|   //   if (!posterElement.value) return; | ||||
|   //   const { height, width } = posterElement.value.getBoundingClientRect(); | ||||
|   //   return { | ||||
|   //     height: Math.ceil(height), | ||||
|   //     width: Math.ceil(width) | ||||
|   //   }; | ||||
|   // }); | ||||
|  | ||||
|   // import img from "../directives/v-image"; | ||||
|   //   directives: { | ||||
|   //     img: img | ||||
|   //   }, | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
|   .movie-item { | ||||
|     padding: 15px; | ||||
|     width: 100%; | ||||
|     background-color: var(--background-color); | ||||
|  | ||||
|     &:hover &__info > p { | ||||
|       color: $text-color; | ||||
|     } | ||||
|  | ||||
|     &__poster { | ||||
|       text-decoration: none; | ||||
|       color: $text-color-70; | ||||
|       font-weight: 300; | ||||
|       position: relative; | ||||
|       transform: scale(0.97) translateZ(0); | ||||
|  | ||||
|       &::before { | ||||
|         content: ""; | ||||
|         position: absolute; | ||||
|         z-index: 1; | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
|         background-color: var(--background-color); | ||||
|         transition: 1s background-color ease; | ||||
|       } | ||||
|  | ||||
|       &:hover { | ||||
|         transform: scale(1.03); | ||||
|         box-shadow: 0 0 10px rgba($dark, 0.1); | ||||
|       } | ||||
|  | ||||
|       &.is-loaded::before { | ||||
|         background-color: transparent; | ||||
|       } | ||||
|  | ||||
|       img { | ||||
|         width: 100%; | ||||
|         border-radius: 10px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__info { | ||||
|       padding-top: 10px; | ||||
|       font-weight: 300; | ||||
|  | ||||
|       > p { | ||||
|         color: $text-color-70; | ||||
|         margin: 0; | ||||
|         font-size: 14px; | ||||
|         letter-spacing: 0.5px; | ||||
|         transition: color 0.5s ease; | ||||
|         cursor: pointer; | ||||
|         @include mobile-ls-min { | ||||
|           font-size: 12px; | ||||
|         } | ||||
|         @include tablet-min { | ||||
|           font-size: 14px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__title { | ||||
|       font-weight: 400; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										203
									
								
								src/components/ResultsSection.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,203 @@ | ||||
| <template> | ||||
|   <div ref="resultSection" class="resultSection"> | ||||
|     <page-header v-bind="{ title, info, shortList }" /> | ||||
|  | ||||
|     <div | ||||
|       v-if="!loadedPages.includes(1) && loading == false" | ||||
|       class="button-container" | ||||
|     > | ||||
|       <seasoned-button class="load-button" :full-width="true" @click="loadLess" | ||||
|         >load previous</seasoned-button | ||||
|       > | ||||
|     </div> | ||||
|  | ||||
|     <results-list v-bind="{ results, shortList, loading }" /> | ||||
|     <loader v-if="loading" /> | ||||
|  | ||||
|     <div ref="loadMoreButton" class="button-container"> | ||||
|       <seasoned-button | ||||
|         v-if="!loading && !shortList && page != totalPages && results.length" | ||||
|         class="load-button" | ||||
|         :full-width="true" | ||||
|         @click="loadMore" | ||||
|         >load more</seasoned-button | ||||
|       > | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, ref, computed, onMounted } from "vue"; | ||||
|   import PageHeader from "@/components/PageHeader.vue"; | ||||
|   import ResultsList from "@/components/ResultsList.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import Loader from "@/components/ui/Loader.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type { IList, ListResults } from "../interfaces/IList"; | ||||
|   import type ISection from "../interfaces/ISection"; | ||||
|  | ||||
|   interface Props extends ISection { | ||||
|     title: string; | ||||
|     apiFunction: (page: number) => Promise<IList>; | ||||
|     shortList?: boolean; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|  | ||||
|   const results: Ref<ListResults> = ref([]); | ||||
|   const page: Ref<number> = ref(1); | ||||
|   const loadedPages: Ref<number[]> = ref([]); | ||||
|   const totalResults: Ref<number> = ref(0); | ||||
|   const totalPages: Ref<number> = ref(0); | ||||
|   const loading: Ref<boolean> = ref(true); | ||||
|   const autoLoad: Ref<boolean> = ref(false); | ||||
|   const observer: Ref<IntersectionObserver> = ref(null); | ||||
|   const resultSection = ref(null); | ||||
|   const loadMoreButton = ref(null); | ||||
|  | ||||
|   function pageCountString(_page: number, _totalPages: number) { | ||||
|     return `Page ${_page} of ${_totalPages}`; | ||||
|   } | ||||
|  | ||||
|   function resultCountString(_results: ListResults, _totalResults: number) { | ||||
|     const loadedResults = _results.length; | ||||
|     const __totalResults = _totalResults < 10000 ? _totalResults : "∞"; | ||||
|     return `${loadedResults} of ${__totalResults} results`; | ||||
|   } | ||||
|  | ||||
|   function setLoading(state: boolean) { | ||||
|     loading.value = state; | ||||
|   } | ||||
|  | ||||
|   const info = computed(() => { | ||||
|     if (results.value.length === 0) return [null, null]; | ||||
|  | ||||
|     const pageCount = pageCountString(page.value, totalPages.value); | ||||
|     const resultCount = resultCountString(results.value, totalResults.value); | ||||
|     return [pageCount, resultCount]; | ||||
|   }); | ||||
|  | ||||
|   function getPageFromUrl() { | ||||
|     const _page = new URLSearchParams(window.location.search).get("page"); | ||||
|     if (!_page) return null; | ||||
|  | ||||
|     return Number(_page); | ||||
|   } | ||||
|  | ||||
|   function updateQueryParams() { | ||||
|     const params = new URLSearchParams(window.location.search); | ||||
|     if (params.has("page")) { | ||||
|       params.set("page", page.value?.toString()); | ||||
|     } else if (page.value > 1) { | ||||
|       params.append("page", page.value?.toString()); | ||||
|     } | ||||
|  | ||||
|     window.history.replaceState( | ||||
|       {}, | ||||
|       "search", | ||||
|       `${window.location.protocol}//${window.location.hostname}${ | ||||
|         window.location.port ? `:${window.location.port}` : "" | ||||
|       }${window.location.pathname}${ | ||||
|         params.toString().length ? `?${params}` : "" | ||||
|       }` | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function getListResults(front = false) { | ||||
|     props | ||||
|       .apiFunction(page.value) | ||||
|       .then(listResponse => { | ||||
|         if (!front) | ||||
|           results.value = results.value.concat(...listResponse.results); | ||||
|         else results.value = listResponse.results.concat(...results.value); | ||||
|  | ||||
|         page.value = listResponse.page; | ||||
|         loadedPages.value.push(page.value); | ||||
|         loadedPages.value = loadedPages.value.sort((a, b) => a - b); | ||||
|         totalPages.value = listResponse.total_pages; | ||||
|         totalResults.value = listResponse.total_results; | ||||
|       }) | ||||
|       .then(updateQueryParams) | ||||
|       .finally(() => setLoading(false)); | ||||
|   } | ||||
|  | ||||
|   function loadMore() { | ||||
|     if (!autoLoad.value) { | ||||
|       autoLoad.value = true; | ||||
|     } | ||||
|  | ||||
|     loading.value = true; | ||||
|     const maxPage = [...loadedPages.value].slice(-1)[0]; | ||||
|  | ||||
|     if (Number.isNaN(maxPage)) return; | ||||
|     page.value = maxPage + 1; | ||||
|     getListResults(); | ||||
|   } | ||||
|  | ||||
|   function loadLess() { | ||||
|     loading.value = true; | ||||
|     const minPage = loadedPages.value[0]; | ||||
|     if (minPage === 1) return; | ||||
|  | ||||
|     page.value = minPage - 1; | ||||
|     getListResults(true); | ||||
|   } | ||||
|  | ||||
|   function handleButtonIntersection(entries) { | ||||
|     entries.map(entry => | ||||
|       entry.isIntersecting && autoLoad.value ? loadMore() : null | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function setupAutoloadObserver() { | ||||
|     observer.value = new IntersectionObserver(handleButtonIntersection, { | ||||
|       root: resultSection.value.$el, | ||||
|       rootMargin: "0px", | ||||
|       threshold: 0 | ||||
|     }); | ||||
|  | ||||
|     observer.value.observe(loadMoreButton.value); | ||||
|   } | ||||
|  | ||||
|   page.value = getPageFromUrl() || page.value; | ||||
|   if (results.value?.length === 0) getListResults(); | ||||
|   onMounted(() => { | ||||
|     if (!props?.shortList) setupAutoloadObserver(); | ||||
|   }); | ||||
|  | ||||
|   //   beforeDestroy() { | ||||
|   //     this.observer = undefined; | ||||
|   //   } | ||||
|   // }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
|   .resultSection { | ||||
|     background-color: var(--background-color); | ||||
|   } | ||||
|  | ||||
|   .button-container { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     display: flex; | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   .load-button { | ||||
|     margin: 2rem 0; | ||||
|  | ||||
|     @include mobile { | ||||
|       margin: 1rem 0; | ||||
|     } | ||||
|  | ||||
|     &:last-of-type { | ||||
|       margin-bottom: 4rem; | ||||
|  | ||||
|       @include mobile { | ||||
|         margin-bottom: 2rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
| @@ -1,102 +0,0 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <list-header :title="title" :info="resultCount" :sticky="true" /> | ||||
|  | ||||
|     <results-list :results="results" /> | ||||
|      | ||||
|     <div v-if="page < totalPages" class="fullwidth-button"> | ||||
|       <seasoned-button @click="loadMore">load more</seasoned-button> | ||||
|     </div> | ||||
|  | ||||
|     <loader v-if="!results.length" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import { searchTmdb } from '@/api' | ||||
| import ListHeader from '@/components/ListHeader' | ||||
| import ResultsList from '@/components/ResultsList' | ||||
| import SeasonedButton from '@/components/ui/SeasonedButton' | ||||
| import Loader from '@/components/ui/Loader' | ||||
|  | ||||
| export default { | ||||
|   components: { ListHeader, ResultsList, SeasonedButton, Loader }, | ||||
|   props: { | ||||
|     propQuery: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     }, | ||||
|     propPage: { | ||||
|       type: Number, | ||||
|       required: false | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       loading: true, | ||||
|       query: String, | ||||
|       title: String, | ||||
|       page: Number, | ||||
|       totalPages: 0, | ||||
|       results: [], | ||||
|       totalResults: [] | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     resultCount() { | ||||
|       const loadedResults = this.results.length | ||||
|       const totalResults = this.totalResults < 10000 ? this.totalResults : '∞' | ||||
|       return `${loadedResults} of ${totalResults} results` | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     search(query=this.query, page=this.page) { | ||||
|       searchTmdb(query, page) | ||||
|         .then(this.parseResponse) | ||||
|     }, | ||||
|     parseResponse(data) { | ||||
|       if (this.results.length > 0) { | ||||
|         this.results.push(...data.results) | ||||
|       } else { | ||||
|         this.results = data.results | ||||
|       } | ||||
|  | ||||
|       this.totalPages = data.total_pages | ||||
|       this.totalResults = data.total_results || data.results.length | ||||
|  | ||||
|       this.loading = false | ||||
|     }, | ||||
|     loadMore() { | ||||
|       this.page++ | ||||
|  | ||||
|       window.history.replaceState({}, 'search', `/#/search?query=${this.query}&page=${this.page}`) | ||||
|       this.search() | ||||
|     } | ||||
|   }, | ||||
|   created() { | ||||
|     const { query, page } = this.$route.query | ||||
|  | ||||
|     if (!query) { | ||||
|       // abort | ||||
|       console.error('abort, no query') | ||||
|     } | ||||
|     this.query = decodeURIComponent(query) | ||||
|     this.page = page ? page : 1 | ||||
|     this.title = `Search results: ${this.query}` | ||||
|  | ||||
|     this.search() | ||||
|   } | ||||
| } | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .fullwidth-button { | ||||
|   width: 100%; | ||||
|   margin: 1rem 0; | ||||
|   padding-bottom: 2rem; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
| } | ||||
|  | ||||
| </style> | ||||
| @@ -1,279 +0,0 @@ | ||||
| <template> | ||||
|   <div> | ||||
|      | ||||
|     <div class="search"> | ||||
|       <input | ||||
|         type="text" | ||||
|         placeholder="Search for a movie or show" | ||||
|         autocorrect="off" | ||||
|         autocapitalize="off" | ||||
|         v-model="query"  | ||||
|         @input="handleInput"  | ||||
|         @click="focus = true" | ||||
|         @keydown.escape="handleEscape" | ||||
|         @keyup.enter="handleSubmit" | ||||
|         @keydown.up="navigateUp" | ||||
|         @keydown.down="navigateDown" /> | ||||
|  | ||||
|       <svg class="search--icon" fill="currentColor"><use xlink:href="#iconSearch"></use></svg> | ||||
|     </div>  | ||||
|  | ||||
|     <transition name="fade"> | ||||
|       <div class="dropdown" v-if="!disabled && focus && query.length > 0"> | ||||
|         <div class="dropdown--results"> | ||||
|  | ||||
|           <ul v-for="(item, index) in elasticSearchResults" | ||||
|               @click="$popup.open(item.id, item.type)" | ||||
|               :class="{ active: index + 1 === selectedResult}"> | ||||
|                  | ||||
|               {{ item.name }} | ||||
|           </ul> | ||||
|  | ||||
|         </div> | ||||
|  | ||||
|         <seasoned-button class="end-section" fullWidth="true"  | ||||
|           @click="focus = false" :active="elasticSearchResults.length + 1 === selectedResult"> | ||||
|           close | ||||
|         </seasoned-button> | ||||
|       </div> | ||||
|     </transition> | ||||
| </div> | ||||
|  | ||||
|  | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import SeasonedButton from '@/components/ui/SeasonedButton' | ||||
|  | ||||
| import { elasticSearchMoviesAndShows } from '@/api' | ||||
| import config from '@/config.json' | ||||
|  | ||||
| export default { | ||||
|   name: 'SearchInput', | ||||
|   components: { | ||||
|     SeasonedButton | ||||
|   }, | ||||
|   props: ['value'], | ||||
|   data() { | ||||
|     return { | ||||
|       query: this.value, | ||||
|       focus: false, | ||||
|       disabled: false, | ||||
|       scrollListener: undefined, | ||||
|       scrollDistance: 0, | ||||
|       elasticSearchResults: '', | ||||
|       selectedResult: 0 | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     focus: function(val) { | ||||
|       if (val === true) { | ||||
|         window.addEventListener('scroll', this.disableFocus) | ||||
|       } else { | ||||
|         window.removeEventListener('scroll', this.disableFocus) | ||||
|         this.scrollDistance = 0 | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   beforeMount() { | ||||
|     const elasticUrl = config.ELASTIC_URL | ||||
|     if (elasticUrl === undefined || elasticUrl === false || elasticUrl === '') { | ||||
|       this.disabled = true | ||||
|     } | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     console.log('scroll eventlistener not removed, destroying!') | ||||
|     window.removeEventListener('scroll', this.disableFocus) | ||||
|   }, | ||||
|   methods: { | ||||
|     navigateDown() { | ||||
|       this.focus = true | ||||
|       this.selectedResult++ | ||||
|     }, | ||||
|     navigateUp() { | ||||
|       this.focus = true | ||||
|       this.selectedResult-- | ||||
|     }, | ||||
|     handleInput(e){ | ||||
|       this.selectedResult = 0 | ||||
|       this.$emit('input', this.query); | ||||
|  | ||||
|       if (! this.focus) { | ||||
|         this.focus = true; | ||||
|       } | ||||
|  | ||||
|       elasticSearchMoviesAndShows(this.query) | ||||
|       .then(resp => { | ||||
|         const data = resp.hits.hits | ||||
|  | ||||
|         this.elasticSearchResults = data.map(item => { | ||||
|           const index = item._index.slice(0, -1) | ||||
|           if (index === 'movie' || item._source.original_title) { | ||||
|             return { | ||||
|               name: item._source.original_title, | ||||
|               id: item._source.id, | ||||
|               type: 'movie' | ||||
|             } | ||||
|           } else if (index === 'show' || item._source.original_name) { | ||||
|             return { | ||||
|               name: item._source.original_name, | ||||
|               id: item._source.id, | ||||
|               type: 'show' | ||||
|             } | ||||
|           } | ||||
|         }) | ||||
|         console.log(this.elasticSearchResults) | ||||
|       }) | ||||
|     }, | ||||
|     handleSubmit() { | ||||
|       let searchResults = this.elasticSearchResults | ||||
|  | ||||
|       if (this.selectedResult > searchResults.length) { | ||||
|         this.focus = false | ||||
|         this.selectedResult = 0 | ||||
|       } else if (this.selectedResult > 0) { | ||||
|         const resultItem = searchResults[this.selectedResult - 1] | ||||
|         this.$popup.open(resultItem.id, resultItem.type) | ||||
|       } else { | ||||
|         const encodedQuery = encodeURI(this.query.replace('/ /g, "+"')) | ||||
|         this.$router.push({ name: 'search', query: { query: encodedQuery }}); | ||||
|         this.focus = false | ||||
|         this.selectedResult = 0 | ||||
|       } | ||||
|     }, | ||||
|     handleEscape() { | ||||
|       if (this.$popup.isOpen) { | ||||
|         console.log('THIS WAS FUCKOING OPEN!') | ||||
|       } else { | ||||
|         this.focus = false | ||||
|       } | ||||
|     }, | ||||
|     disableFocus(_) { | ||||
|       this.focus = false | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
| @import './src/scss/main'; | ||||
|  | ||||
|  | ||||
| .fade-enter-active { | ||||
|   transition: opacity .2s; | ||||
| } | ||||
| .fade-leave-active { | ||||
|   transition: opacity .2s; | ||||
| } | ||||
| .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { | ||||
|   opacity: 0; | ||||
| } | ||||
|  | ||||
| .dropdown { | ||||
|   width: 100%; | ||||
|   position: relative; | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
|   z-index: 5; | ||||
|   min-height: $header-size; | ||||
|   right: 0px; | ||||
|   background-color: $background-color-secondary; | ||||
|  | ||||
|   @include mobile-only { | ||||
|     position: fixed; | ||||
|     top: 50px; | ||||
|     padding-top: 20px; | ||||
|     width: calc(100%); | ||||
|   } | ||||
|  | ||||
|   &--results { | ||||
|     padding-left: 60px; | ||||
|     width: 100%; | ||||
|  | ||||
|     @include mobile-only { | ||||
|       padding-left: 45px; | ||||
|     } | ||||
|  | ||||
|     > ul { | ||||
|       font-size: 1.3rem; | ||||
|       padding: 0; | ||||
|       margin: 0.2rem 0; | ||||
|       width: calc(100% - 25px); | ||||
|       max-width: fit-content; | ||||
|  | ||||
|       list-style: none;       | ||||
|       color: rgba(0, 0, 0, 0.5); | ||||
|       text-transform: capitalize; | ||||
|       cursor: pointer; | ||||
|       border-bottom: 2px solid transparent; | ||||
|  | ||||
|       white-space: nowrap; | ||||
|       text-overflow: ellipsis; | ||||
|       overflow: hidden; | ||||
|       color: $text-color-50; | ||||
|  | ||||
|       &.active, &:hover, &:active { | ||||
|         color: $text-color; | ||||
|         border-bottom: 2px solid $text-color; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .search { | ||||
|   height: $header-size; | ||||
|   display: flex; | ||||
|   position: fixed; | ||||
|   flex-wrap: wrap; | ||||
|   z-index: 5; | ||||
|   border: 0; | ||||
|   background-color: $background-color-secondary; | ||||
|  | ||||
|   // TODO check if this is for mobile | ||||
|   width: calc(100% - 110px); | ||||
|   top: 0; | ||||
|   right: 55px; | ||||
|  | ||||
|   @include tablet-min{ | ||||
|     position: relative; | ||||
|     width: 100%; | ||||
|     right: 0px; | ||||
|   } | ||||
|  | ||||
|   input { | ||||
|     display: block; | ||||
|     width: 100%; | ||||
|     padding: 13px 20px 13px 45px; | ||||
|     outline: none; | ||||
|     margin: 0; | ||||
|     border: 0; | ||||
|     background-color: $background-color-secondary; | ||||
|     font-weight: 300; | ||||
|     font-size: 19px; | ||||
|     color: $text-color; | ||||
|     transition: background-color .5s ease, color .5s ease; | ||||
|  | ||||
|     @include tablet-min { | ||||
|       padding: 13px 30px 13px 60px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &--icon{ | ||||
|     width: 20px; | ||||
|     height: 20px; | ||||
|     fill: $text-color-50; | ||||
|     transition: fill 0.5s ease; | ||||
|     pointer-events: none; | ||||
|     position: absolute; | ||||
|     left: 15px; | ||||
|     top: 15px; | ||||
|  | ||||
|     @include tablet-min{ | ||||
|       top: 27px; | ||||
|       left: 25px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,162 +0,0 @@ | ||||
| <template> | ||||
|   <section class="profile"> | ||||
|     <div class="profile__content" v-if="userLoggedIn"> | ||||
|       <section class='settings'> | ||||
|         <h3 class='settings__header'>Plex account</h3> | ||||
|         <span class="settings__info">Sign in to your plex account to get information about recently added movies and to see your watch history</span> | ||||
|  | ||||
|         <form class="form"> | ||||
|           <seasoned-input placeholder="plex username" icon="Email" :value.sync="plexUsername"/> | ||||
|           <seasoned-input placeholder="plex password" icon="Keyhole" type="password" | ||||
|             :value.sync="plexPassword" @submit="authenticatePlex" /> | ||||
|  | ||||
|           <seasoned-button @click="authenticatePlex">link plex account</seasoned-button> | ||||
|  | ||||
|           <seasoned-messages :messages.sync="messages" /> | ||||
|         </form> | ||||
|  | ||||
|         <hr class='setting__divider'> | ||||
|  | ||||
|         <h3 class='settings__header'>Change password</h3> | ||||
|         <form class="form"> | ||||
|           <seasoned-input placeholder="new password" icon="Keyhole" type="password" | ||||
|             :value.sync="newPassword" /> | ||||
|  | ||||
|           <seasoned-input placeholder="repeat new password" icon="Keyhole" type="password" | ||||
|             :value.sync="newPasswordRepeat" /> | ||||
|  | ||||
|           <seasoned-button @click="changePassword">change password</seasoned-button> | ||||
|         </form> | ||||
|  | ||||
|         <hr class='setting__divider'> | ||||
|       </section> | ||||
|     </div> | ||||
|  | ||||
|     <section class="not-found" v-else> | ||||
|       <div class="not-found__content"> | ||||
|         <h2 class="not-found__title">Authentication Request Failed</h2> | ||||
|         <router-link :to="{name: 'signin'}" exact title="Sign in here"> | ||||
|           <button class="not-found__button button">Sign In</button> | ||||
|         </router-link> | ||||
|       </div> | ||||
|     </section> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import storage from '@/storage' | ||||
| import SeasonedInput from '@/components/ui/SeasonedInput' | ||||
| import SeasonedButton from '@/components/ui/SeasonedButton' | ||||
| import SeasonedMessages from '@/components/ui/SeasonedMessages' | ||||
|  | ||||
| import { plexAuthenticate } from '@/api' | ||||
|  | ||||
| export default { | ||||
|   components: { SeasonedInput, SeasonedButton, SeasonedMessages }, | ||||
|   data(){ | ||||
|     return{ | ||||
|       userLoggedIn: '', | ||||
|       messages: [], | ||||
|       plexUsername: null, | ||||
|       plexPassword: null, | ||||
|       newPassword: null, | ||||
|       newPasswordRepeat: null | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     setValue(l, t) { | ||||
|       this[l] = t | ||||
|     }, | ||||
|     changePassword() { | ||||
|       return | ||||
|     }, | ||||
|     authenticatePlex() { | ||||
|       let username = this.plexUsername | ||||
|       let password = this.plexPassword | ||||
|  | ||||
|       plexAuthenticate(username, password) | ||||
|       .then(resp => { | ||||
|         const data = resp.data | ||||
|         this.messages.push({ type: 'success', title: 'Authenticated with plex', message: 'Successfully linked plex account with seasoned request' }) | ||||
|  | ||||
|         console.log('response from plex:', data.username) | ||||
|       }) | ||||
|       .catch(error => { | ||||
|         console.error(error); | ||||
|  | ||||
|         this.messages.push({ type: 'error', title: 'Something went wrong', message: error.message }) | ||||
|       }) | ||||
|     } | ||||
|   }, | ||||
|   created(){ | ||||
|     if (localStorage.getItem('token')){ | ||||
|       this.userLoggedIn = true | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|  | ||||
| a { | ||||
|    text-decoration: none; | ||||
| } | ||||
|  | ||||
| // DUPLICATE CODE | ||||
| .form { | ||||
|   > div:last-child { | ||||
|     margin-top: 1rem; | ||||
|   } | ||||
|  | ||||
|   &__group{ | ||||
|      justify-content: unset; | ||||
|      &__input-icon { | ||||
|         margin-top: 8px; | ||||
|         height: 22px; | ||||
|         width: 22px; | ||||
|      } | ||||
|      &-input { | ||||
|         padding: 10px 5px 10px 45px; | ||||
|         height: 40px; | ||||
|         font-size: 17px; | ||||
|         width: 75%; | ||||
|         @include desktop-min { | ||||
|            width: 400px; | ||||
|         } | ||||
|      } | ||||
|   } | ||||
| } | ||||
| .settings { | ||||
|    padding: 35px; | ||||
|  | ||||
|    &__header { | ||||
|       margin: 0; | ||||
|       line-height: 16px; | ||||
|       color: $text-color; | ||||
|       font-weight: 300; | ||||
|       margin-bottom: 20px; | ||||
|       text-transform: uppercase; | ||||
|    } | ||||
|    &__info { | ||||
|       display: block; | ||||
|       margin-bottom: 25px; | ||||
|    } | ||||
|    hr { | ||||
|       display: block; | ||||
|       height: 1px; | ||||
|       border: 0; | ||||
|       border-bottom: 1px solid rgba(8, 28, 36, 0.05); | ||||
|       margin-top: 30px; | ||||
|       margin-bottom: 70px; | ||||
|       margin-left: 20px; | ||||
|       width: 96%; | ||||
|       text-align: left; | ||||
|    } | ||||
|    span { | ||||
|       font-weight: 200; | ||||
|       size: 16px; | ||||
|    } | ||||
| } | ||||
| </style> | ||||
| @@ -1,99 +0,0 @@ | ||||
| <template> | ||||
|   <section> | ||||
|     <h1>Sign in</h1> | ||||
|  | ||||
|     <seasoned-input placeholder="username" icon="Email" type="username" :value.sync="username" /> | ||||
|     <seasoned-input placeholder="password" icon="Keyhole" type="password" :value.sync="password" @enter="signin"/> | ||||
|  | ||||
|     <seasoned-button @click="signin">sign in</seasoned-button> | ||||
|  | ||||
|     <router-link class="link" to="/register">Don't have a user? Register here</router-link> | ||||
|     <seasoned-messages :messages.sync="messages"></seasoned-messages> | ||||
|  | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
|  | ||||
|  | ||||
| <script> | ||||
| import axios from 'axios' | ||||
| import storage from '../storage' | ||||
| import SeasonedInput from '@/components/ui/SeasonedInput' | ||||
| import SeasonedButton from '@/components/ui/SeasonedButton' | ||||
| import SeasonedMessages from '@/components/ui/SeasonedMessages' | ||||
|  | ||||
| export default { | ||||
|   components: { SeasonedInput, SeasonedButton, SeasonedMessages }, | ||||
|   data(){ | ||||
|     return{ | ||||
|       messages: [], | ||||
|       username: null, | ||||
|       password: null | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     setValue(l, t) { | ||||
|       this[l] = t | ||||
|     }, | ||||
|     signin(){ | ||||
|       let username = this.username; | ||||
|       let password = this.password; | ||||
|  | ||||
|       axios.post(`https://api.kevinmidboe.com/api/v1/user/login`, { | ||||
|         username: username, | ||||
|         password: password | ||||
|       }) | ||||
|       .then(resp => { | ||||
|         let data = resp.data; | ||||
|         if (data.success){ | ||||
|           localStorage.setItem('token', data.token); | ||||
|           localStorage.setItem('username', username); | ||||
|           localStorage.setItem('admin', data.admin); | ||||
|            | ||||
|           eventHub.$emit('setUserStatus'); | ||||
|           this.$router.push({ name: 'profile' }) | ||||
|         } | ||||
|       }) | ||||
|       .catch(error => { | ||||
|         if (error.message.endsWith('401')) { | ||||
|           this.messages.push({ type: 'warning', title: 'Access denied', message: 'Incorrect username or password' }) | ||||
|         } | ||||
|         else { | ||||
|           this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message }) | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|   }, | ||||
|   created(){ | ||||
|     document.title = 'Sign in' + storage.pageTitlePostfix; | ||||
|     storage.backTitle = document.title; | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
|  | ||||
| section { | ||||
|   padding: 1.3rem; | ||||
|  | ||||
|   @include tablet-min { | ||||
|     padding: 4rem; | ||||
|   } | ||||
|  | ||||
|   h1 { | ||||
|     margin: 0; | ||||
|     line-height: 16px; | ||||
|     color: $text-color; | ||||
|     font-weight: 300; | ||||
|     margin-bottom: 20px; | ||||
|     text-transform: uppercase; | ||||
|   } | ||||
|  | ||||
|   .link { | ||||
|     display: block; | ||||
|     width: max-content; | ||||
|     margin-top: 1rem; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,505 +0,0 @@ | ||||
| <template> | ||||
|   <div v-if="show" class="container"> | ||||
|     <h2 class="torrentHeader-text">Searching for: {{ editedSearchQuery || query }}</h2> | ||||
| <!--     <div class="torrentHeader"> | ||||
|       <span class="torrentHeader-text">Searching for: </span> | ||||
|  | ||||
|  | ||||
|       <span id="search" :contenteditable="editSearchQuery ? true : false" class="torrentHeader-text editable">{{ editedSearchQuery || query }}</span> | ||||
|  | ||||
|  | ||||
|       <svg v-if="!editSearchQuery" class="torrentHeader-editIcon" @click="toggleEditSearchQuery"> | ||||
|         <use xlink:href="#icon_radar"></use> | ||||
|       </svg> | ||||
|  | ||||
|       <svg v-else class="torrentHeader-editIcon" @click="toggleEditSearchQuery"> | ||||
|         <use xlink:href="#icon_check"></use> | ||||
|       </svg> | ||||
|  | ||||
|     </div> --> | ||||
|  | ||||
|     <div v-if="listLoaded"> | ||||
|       <div v-if="torrents.length > 0"> | ||||
|         <ul class="filter"> | ||||
|           <li class="filter-item" v-for="(item, index) in release_types" @click="applyFilter(item, index)" :class="{'active': item === selectedRelaseType}">{{ item }}</li> | ||||
|         </ul> | ||||
|  | ||||
|  | ||||
|         <table> | ||||
|           <tr class="table__header noselect"> | ||||
|             <th @click="sortTable('name')" :class="selectedSortableClass('name')"> | ||||
|               <span>Name</span> | ||||
|               <span v-if="prevCol === 'name' && direction">↑</span> | ||||
|               <span v-if="prevCol === 'name' && !direction">↓</span> | ||||
|             </th> | ||||
|             <th @click="sortTable('seed')" :class="selectedSortableClass('seed')"> | ||||
|               <span>Seed</span> | ||||
|               <span v-if="prevCol === 'seed' && direction">↑</span> | ||||
|               <span v-if="prevCol === 'seed' && !direction">↓</span> | ||||
|             </th> | ||||
|             <th @click="sortTable('size')" :class="selectedSortableClass('size')"> | ||||
|               <span>Size</span> | ||||
|               <span v-if="prevCol === 'size' && direction">↑</span> | ||||
|               <span v-if="prevCol === 'size' && !direction">↓</span> | ||||
|             <th> | ||||
|               <span>Magnet</span> | ||||
|             </th> | ||||
|           </tr> | ||||
|           <tr v-for="torrent in torrents" class="table__content"> | ||||
|             <td @click="expand($event, torrent.name)">{{ torrent.name }}</td> | ||||
|             <td @click="expand($event, torrent.name)">{{ torrent.seed }}</td> | ||||
|             <td @click="expand($event, torrent.name)">{{ torrent.size }}</td> | ||||
|             <td @click="sendTorrent(torrent.magnet, torrent.name, $event)" class="download"> | ||||
|               <svg class="download__icon"><use xlink:href="#iconUnmatched"></use></svg> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </table> | ||||
|  | ||||
|         <div style=" | ||||
|           display: flex; | ||||
|           justify-content: center; | ||||
|           padding: 1rem; | ||||
|         "> | ||||
|           <seasonedButton @click="resetTorrentsAndToggleEditSearchQuery">Edit search query</seasonedButton> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|     <div v-else style="display: flex; | ||||
|                        padding-bottom: 2rem; | ||||
|                        justify-content: center; | ||||
|                        flex-direction: column; | ||||
|                        width: 100%; | ||||
|                        align-items: center;"> | ||||
|       <h2>No results found</h2> | ||||
|       <br /> | ||||
|  | ||||
|       <div class="editQuery" v-if="editSearchQuery"> | ||||
|  | ||||
|         <seasonedInput placeholder="Torrent query" icon="_torrents" :value.sync="editedSearchQuery" @enter="fetchTorrents(editedSearchQuery)" /> | ||||
|  | ||||
|         <div style="height: 45px; width: 5px;"></div> | ||||
|  | ||||
|         <seasonedButton @click="fetchTorrents(editedSearchQuery)">Search</seasonedButton> | ||||
|       </div> | ||||
|  | ||||
|       <seasonedButton @click="toggleEditSearchQuery" :active="editSearchQuery ? true : false">Edit search query</seasonedButton> | ||||
|     </div> | ||||
|     </div> | ||||
|     <div v-else class="torrentloader"><i></i></div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import storage from '@/storage' | ||||
| import store from '@/store' | ||||
| import { sortableSize } from '@/utils' | ||||
| import { searchTorrents, addMagnet } from '@/api' | ||||
|  | ||||
| import SeasonedButton from '@/components/ui/SeasonedButton' | ||||
| import SeasonedInput from '@/components/ui/SeasonedInput' | ||||
|  | ||||
| export default { | ||||
|   components: { SeasonedButton, SeasonedInput }, | ||||
|   props: { | ||||
|     query: { | ||||
|       type: String, | ||||
|       require: true | ||||
|     }, | ||||
|     tmdb_id: { | ||||
|       type: Number, | ||||
|       require: true | ||||
|     }, | ||||
|     tmdb_type: String, | ||||
|     admin: String, | ||||
|     show: Boolean | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       listLoaded: false, | ||||
|       torrents: [], | ||||
|       torrentResponse: undefined, | ||||
|       currentPage: 0, | ||||
|       prevCol: '', | ||||
|       direction: false, | ||||
|       release_types: ['all'], | ||||
|       selectedRelaseType: 'all', | ||||
|       editSearchQuery: false, | ||||
|       editedSearchQuery: '' | ||||
|     } | ||||
|   }, | ||||
|   beforeMount() { | ||||
|     if (localStorage.getItem('admin')) { | ||||
|       this.fetchTorrents() | ||||
|     } | ||||
|     store.dispatch('torrentModule/reset') | ||||
|   }, | ||||
|   methods: { | ||||
|     selectedSortableClass(headerName) { | ||||
|       return headerName === this.prevCol ? 'active' : '' | ||||
|     }, | ||||
|     resetTorrentsAndToggleEditSearchQuery() { | ||||
|       this.torrents = [] | ||||
|       this.toggleEditSearchQuery() | ||||
|     }, | ||||
|     toggleEditSearchQuery() { | ||||
|       this.editSearchQuery = !this.editSearchQuery; | ||||
|     }, | ||||
|     expand(event, name) { | ||||
|       const existingExpandedElement = document.getElementsByClassName('expanded')[0] | ||||
|  | ||||
|       if (existingExpandedElement) { | ||||
|         console.log('exists') | ||||
|         const expandedSibling = event.target.parentNode.nextSibling.className === 'expanded' | ||||
|  | ||||
|         existingExpandedElement.remove() | ||||
|  | ||||
|         if (expandedSibling) { | ||||
|           console.log('sibling is here') | ||||
|           return | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       console.log('expand event', event) | ||||
|       const nameRow = document.createElement('tr') | ||||
|       const nameCol = document.createElement('td') | ||||
|       nameRow.className = 'expanded' | ||||
|       nameCol.innerText = name | ||||
|  | ||||
|       nameRow.appendChild(nameCol) | ||||
|  | ||||
|       event.target.parentNode.insertAdjacentElement('afterend', nameRow) | ||||
|     }, | ||||
|     sendTorrent(magnet, name, event){ | ||||
|       this.$notifications.info({ | ||||
|         title: 'Adding torrent 🦜', | ||||
|         description: this.query, | ||||
|         timeout: 3000 | ||||
|       }) | ||||
|  | ||||
|       event.target.parentNode.classList.add('active') | ||||
|  | ||||
|       addMagnet(magnet, name, this.tmdb_id) | ||||
|       .catch((resp) => { console.log('error:', resp.data) }) | ||||
|       .then((resp) => { | ||||
|         console.log('addTorrent resp: ', resp) | ||||
|         this.$notifications.success({ | ||||
|           title: 'Torrent added 🎉', | ||||
|           description: this.query, | ||||
|           timeout: 3000 | ||||
|         }) | ||||
|       }) | ||||
|     }, | ||||
|     sortTable(col, sameDirection=false) { | ||||
|       if (this.prevCol === col && sameDirection === false) { | ||||
|         this.direction = !this.direction | ||||
|       } | ||||
|       console.log('col and more', col, sameDirection) | ||||
|  | ||||
|       switch (col) { | ||||
|         case 'name': | ||||
|           this.sortName() | ||||
|           break | ||||
|         case 'seed': | ||||
|           this.sortSeed() | ||||
|           break | ||||
|         case 'size': | ||||
|           this.sortSize() | ||||
|           break | ||||
|       } | ||||
|       this.prevCol = col | ||||
|     }, | ||||
|     sortName() { | ||||
|       const torrentsCopy = [...this.torrents] | ||||
|       if (this.direction) { | ||||
|         this.torrents = torrentsCopy.sort((a, b) => (a.name < b.name) ? 1 : -1) | ||||
|       } else { | ||||
|         this.torrents = torrentsCopy.sort((a, b) => (a.name > b.name) ? 1 : -1) | ||||
|       } | ||||
|     }, | ||||
|     sortSeed() { | ||||
|       const torrentsCopy = [...this.torrents] | ||||
|       if (this.direction) { | ||||
|         this.torrents = torrentsCopy.sort((a, b) => parseInt(a.seed) - parseInt(b.seed)); | ||||
|       } else { | ||||
|         this.torrents = torrentsCopy.sort((a, b) => parseInt(b.seed) - parseInt(a.seed)); | ||||
|       } | ||||
|     }, | ||||
|     sortSize() { | ||||
|       const torrentsCopy = [...this.torrents] | ||||
|       if (this.direction) { | ||||
|         this.torrents = torrentsCopy.sort((a, b) => parseInt(sortableSize(a.size)) - parseInt(sortableSize(b.size))); | ||||
|       } else { | ||||
|         this.torrents = torrentsCopy.sort((a, b) => parseInt(sortableSize(b.size)) - parseInt(sortableSize(a.size))); | ||||
|       } | ||||
|     }, | ||||
|     findRelaseTypes() { | ||||
|       this.torrents.forEach(item => this.release_types.push(...item.release_type)) | ||||
|       this.release_types = [...new Set(this.release_types)] | ||||
|     }, | ||||
|     applyFilter(item, index) { | ||||
|       this.selectedRelaseType = item; | ||||
|       const torrents = [...this.torrentResponse] | ||||
|  | ||||
|       if (item === 'all') { | ||||
|         this.torrents = torrents | ||||
|         this.sortTable(this.prevCol, true) | ||||
|         return | ||||
|       } | ||||
|  | ||||
|       this.torrents = torrents.filter(torrent => torrent.release_type.includes(item)) | ||||
|       this.sortTable(this.prevCol, true) | ||||
|     }, | ||||
|     updateResultCountInStore() { | ||||
|       store.dispatch('torrentModule/setResults', this.torrents) | ||||
|       store.dispatch('torrentModule/setResultCount', this.torrentResponse.length) | ||||
|     }, | ||||
|     fetchTorrents(query=undefined){ | ||||
|       this.listLoaded = false; | ||||
|       this.editSearchQuery = false; | ||||
|  | ||||
|       searchTorrents(query || this.query, 'all', this.currentPage, storage.token) | ||||
|         .then(data => { | ||||
|             this.torrentResponse = [...data.results]; | ||||
|             this.torrents = data.results; | ||||
|             this.listLoaded = true; | ||||
|         }) | ||||
|         .then(this.updateResultCountInStore) | ||||
|         .then(this.findRelaseTypes) | ||||
|         .catch(e => { | ||||
|           const error = e.toString() | ||||
|           this.errorMessage = error.indexOf('401') != -1 ? 'Permission denied' : 'Nothing found'; | ||||
|           this.listLoaded = true; | ||||
|         }); | ||||
|     }, | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| .expanded { | ||||
|   display: flex; | ||||
|   margin: 0 1rem; | ||||
|   max-width: 100%; | ||||
|   border-left: 1px solid $text-color; | ||||
|   border-right: 1px solid $text-color; | ||||
|   border-bottom: 1px solid $text-color; | ||||
|  | ||||
|   td { | ||||
|     // border-left: 1px solid $c-dark; | ||||
|     word-break: break-all; | ||||
|     padding: 0.5rem 0.15rem; | ||||
|     width: 100%; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
| @import "./src/scss/elements"; | ||||
|  | ||||
| .container { | ||||
|   background-color: $background-color; | ||||
| } | ||||
|  | ||||
| .torrentHeader { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   padding-bottom: 20px; | ||||
|  | ||||
|  | ||||
|   &-text { | ||||
|     font-weight: 400; | ||||
|     text-transform: uppercase; | ||||
|     font-size: 14px; | ||||
|     color: $green; | ||||
|     text-align: center; | ||||
|     margin: 0; | ||||
|  | ||||
|     @include tablet-min { | ||||
|       font-size: 16px | ||||
|     } | ||||
|  | ||||
|     &.editable { | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &-editIcon { | ||||
|     margin-left: 10px; | ||||
|     margin-top: -3px; | ||||
|     width: 22px; | ||||
|     height: 22px; | ||||
|  | ||||
|     &:hover { | ||||
|       fill: $green; | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| table { | ||||
|   border-collapse: collapse; | ||||
|   width: 100%; | ||||
|   table-layout: fixed; | ||||
| } | ||||
|  | ||||
| .table__content, .table__header { | ||||
|   display: flex; | ||||
|   padding: 0; | ||||
|   margin: 0 1rem; | ||||
|   border-left: 1px solid $text-color; | ||||
|   border-right: 1px solid $text-color; | ||||
|   border-bottom: 1px solid $text-color; | ||||
|  | ||||
|   th, td { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     flex-basis: 100%; | ||||
|  | ||||
|     padding: 0.4rem; | ||||
|  | ||||
|     white-space: nowrap; | ||||
|     text-overflow: ellipsis; | ||||
|     overflow: hidden; | ||||
|     min-width: 75px; | ||||
|   } | ||||
|  | ||||
|   th:first-child, td:first-child { | ||||
|     flex: 1; | ||||
|   } | ||||
|  | ||||
|   th:not(:first-child), td:not(:first-child) { | ||||
|     flex: 0.2; | ||||
|   } | ||||
|  | ||||
|   th:nth-child(2), td:nth-child(2) { | ||||
|     flex: 0.1; | ||||
|   } | ||||
|  | ||||
|   @include mobile-only { | ||||
|     th:first-child, td:first-child { | ||||
|       display: none; | ||||
|  | ||||
|       &.show { | ||||
|         display: block; | ||||
|         align: flex-end; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     th:not(:first-child), td:not(:first-child) { | ||||
|       flex: 1; | ||||
|     } | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| .table__content { | ||||
|   td:not(:last-child) { | ||||
|     border-right: 1px solid $text-color; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .table__content:last-child { | ||||
|   margin-bottom: 1rem; | ||||
|  | ||||
|   border-bottom-left-radius: 3px; | ||||
|   border-bottom-right-radius: 3px; | ||||
| } | ||||
|  | ||||
| .table__header { | ||||
|   color: $text-color; | ||||
|   text-transform: uppercase; | ||||
|   cursor: pointer; | ||||
|   background-color: $background-color-secondary; | ||||
|  | ||||
|   border-top: 1px solid $text-color; | ||||
|   border-top-left-radius: 3px; | ||||
|   border-top-right-radius: 3px; | ||||
|  | ||||
|   th { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     font-weight: 400; | ||||
|     letter-spacing: 0.7px; | ||||
|     // font-size: 1.08rem; | ||||
|     font-size: 15px; | ||||
|  | ||||
|     &::before { | ||||
|       content: ''; | ||||
|       min-width: 0.2rem; | ||||
|     } | ||||
|  | ||||
|     span:first-child { | ||||
|       margin-right: 0.6rem; | ||||
|     } | ||||
|     span:nth-child(2) { | ||||
|       margin-right: 0.1rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   th:not(:last-child) { | ||||
|     border-right: 1px solid $text-color; | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| .editQuery { | ||||
|   display: flex; | ||||
|   width: 70%; | ||||
|   justify-content: center; | ||||
|  | ||||
|   @include mobile-only { | ||||
|     width: 90%; | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
| .download { | ||||
|  | ||||
|   &__icon { | ||||
|     fill: $text-color-70; | ||||
|     height: 1.2rem; | ||||
|  | ||||
|     &:hover { | ||||
|       fill: $text-color; | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.active &__icon { | ||||
|     fill: $green; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .torrentloader { | ||||
|   width: 100%; | ||||
|   padding: 2rem 0; | ||||
|  | ||||
|   i { | ||||
|     animation: load 1s linear infinite; | ||||
|     border: 2px solid $text-color; | ||||
|     border-radius: 50%; | ||||
|     display: block; | ||||
|     height: 30px; | ||||
|     left: 50%; | ||||
|     margin: 0 auto; | ||||
|     width: 30px; | ||||
|  | ||||
|     &:after { | ||||
|       border: 5px solid $green; | ||||
|       border-radius: 50%; | ||||
|       content: ''; | ||||
|       left: 10px; | ||||
|       position: absolute; | ||||
|       top: 16px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @keyframes load { | ||||
|   100% { transform: rotate(360deg); } | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										265
									
								
								src/components/header/AutocompleteDropdown.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,265 @@ | ||||
| <template> | ||||
|   <transition name="shut"> | ||||
|     <ul class="dropdown"> | ||||
|       <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events --> | ||||
|       <li | ||||
|         v-for="(result, _index) in searchResults" | ||||
|         :key="`${_index}-${result.title}-${result.type}`" | ||||
|         :class="`result di-${_index} ${_index === index ? 'active' : ''}`" | ||||
|         @click="openPopup(result)" | ||||
|       > | ||||
|         <IconMovie v-if="result.type == 'movie'" class="type-icon" /> | ||||
|         <IconShow v-if="result.type == 'show'" class="type-icon" /> | ||||
|         <span class="title">{{ result.title }}</span> | ||||
|       </li> | ||||
|  | ||||
|       <li | ||||
|         v-if="searchResults.length" | ||||
|         :class="`info di-${searchResults.length}`" | ||||
|       > | ||||
|         <span> Select from list or press enter to search </span> | ||||
|       </li> | ||||
|     </ul> | ||||
|   </transition> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, watch, defineProps } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import IconMovie from "@/icons/IconMovie.vue"; | ||||
|   import IconShow from "@/icons/IconShow.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { elasticSearchMoviesAndShows } from "../../api"; | ||||
|   import { MediaTypes } from "../../interfaces/IList"; | ||||
|   import { Index } from "../../interfaces/IAutocompleteSearch"; | ||||
|   import type { | ||||
|     IAutocompleteResult, | ||||
|     IAutocompleteSearchResults | ||||
|   } from "../../interfaces/IAutocompleteSearch"; | ||||
|  | ||||
|   interface Props { | ||||
|     query?: string; | ||||
|     index?: number; | ||||
|     results?: Array<IAutocompleteResult>; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "update:results", value: Array<IAutocompleteResult>); | ||||
|   } | ||||
|  | ||||
|   const numberOfResults = 10; | ||||
|   const props = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const searchResults: Ref<Array<IAutocompleteResult>> = ref([]); | ||||
|   const keyboardNavigationIndex: Ref<number> = ref(0); | ||||
|  | ||||
|   watch( | ||||
|     () => props.query, | ||||
|     newQuery => { | ||||
|       if (newQuery?.length > 0) | ||||
|         fetchAutocompleteResults(); /* eslint-disable-line no-use-before-define */ | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   function openPopup(result) { | ||||
|     if (!result.id || !result.type) return; | ||||
|  | ||||
|     store.dispatch("popup/open", { ...result }); | ||||
|   } | ||||
|  | ||||
|   function removeDuplicates(_searchResults) { | ||||
|     const filteredResults = []; | ||||
|     _searchResults.forEach(result => { | ||||
|       if (result === undefined) return; | ||||
|       const numberOfDuplicates = filteredResults.filter( | ||||
|         filterItem => filterItem.id === result.id | ||||
|       ); | ||||
|       if (numberOfDuplicates.length >= 1) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       filteredResults.push(result); | ||||
|     }); | ||||
|  | ||||
|     return filteredResults; | ||||
|   } | ||||
|  | ||||
|   function elasticIndexToMediaType(index: Index): MediaTypes { | ||||
|     if (index === Index.Movies) return MediaTypes.Movie; | ||||
|     if (index === Index.Shows) return MediaTypes.Show; | ||||
|  | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) { | ||||
|     const data = elasticResponse.hits.hits; | ||||
|  | ||||
|     const results: Array<IAutocompleteResult> = []; | ||||
|  | ||||
|     data.forEach(item => { | ||||
|       if (!Object.values(Index).includes(item._index)) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       results.push({ | ||||
|         title: item._source?.original_name || item._source.original_title, | ||||
|         id: item._source.id, | ||||
|         adult: item._source.adult, | ||||
|         type: elasticIndexToMediaType(item._index) | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     return removeDuplicates(results).map((el, index) => { | ||||
|       return { ...el, index }; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function fetchAutocompleteResults() { | ||||
|     keyboardNavigationIndex.value = 0; | ||||
|     searchResults.value = []; | ||||
|  | ||||
|     elasticSearchMoviesAndShows(props.query, numberOfResults) | ||||
|       .then(elasticResponse => parseElasticResponse(elasticResponse)) | ||||
|       .then(_searchResults => { | ||||
|         emit("update:results", _searchResults); | ||||
|         searchResults.value = _searchResults; | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   // on load functions | ||||
|   fetchAutocompleteResults(); | ||||
|   // end on load functions | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|   $sizes: 22; | ||||
|  | ||||
|   @for $i from 0 through $sizes { | ||||
|     .dropdown .di-#{$i} { | ||||
|       visibility: visible; | ||||
|       transform-origin: top center; | ||||
|       animation: scaleZ 200ms calc(50ms * #{$i}) ease-in forwards; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @keyframes scaleZ { | ||||
|     0% { | ||||
|       opacity: 0; | ||||
|       transform: scale(0); | ||||
|     } | ||||
|     80% { | ||||
|       transform: scale(1.07); | ||||
|     } | ||||
|     100% { | ||||
|       opacity: 1; | ||||
|       transform: scale(1); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .dropdown { | ||||
|     top: var(--header-size); | ||||
|     position: relative; | ||||
|     height: 100%; | ||||
|     width: 100%; | ||||
|     max-width: 720px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     flex-wrap: wrap; | ||||
|     z-index: 5; | ||||
|  | ||||
|     margin-top: -1px; | ||||
|     border-top: none; | ||||
|     padding: 0; | ||||
|  | ||||
|     @include mobile { | ||||
|       position: fixed; | ||||
|       left: 0; | ||||
|       max-width: 100vw; | ||||
|     } | ||||
|  | ||||
|     @include tablet-min { | ||||
|       top: unset; | ||||
|       --gutter: 1.5rem; | ||||
|       max-width: calc(100% - (2 * var(--gutter))); | ||||
|       margin: -1px var(--gutter) 0 var(--gutter); | ||||
|     } | ||||
|  | ||||
|     @include desktop { | ||||
|       max-width: 720px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   li.result { | ||||
|     background-color: var(--background-95); | ||||
|     color: var(--text-color-50); | ||||
|     padding: 0.5rem 2rem; | ||||
|     list-style: none; | ||||
|  | ||||
|     opacity: 0; | ||||
|     height: 56px; | ||||
|     width: 100%; | ||||
|     visibility: hidden; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     padding: 0.5rem 2rem; | ||||
|  | ||||
|     font-size: 1.4rem; | ||||
|     text-transform: capitalize; | ||||
|     list-style: none; | ||||
|     cursor: pointer; | ||||
|     white-space: nowrap; | ||||
|  | ||||
|     transition: color 0.1s ease, fill 0.4s ease; | ||||
|  | ||||
|     span { | ||||
|       overflow-x: hidden; | ||||
|       text-overflow: ellipsis; | ||||
|       transition: inherit; | ||||
|     } | ||||
|  | ||||
|     &.active, | ||||
|     &:hover, | ||||
|     &:active { | ||||
|       color: var(--text-color); | ||||
|       border-bottom: 2px solid var(--color-green); | ||||
|  | ||||
|       .type-icon { | ||||
|         fill: var(--text-color); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .type-icon { | ||||
|       width: 28px; | ||||
|       height: 28px; | ||||
|       margin-right: 1rem; | ||||
|       transition: inherit; | ||||
|       fill: var(--text-color-50); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   li.info { | ||||
|     visibility: hidden; | ||||
|     opacity: 0; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     padding: 0 1rem; | ||||
|     color: var(--text-color-50); | ||||
|     background-color: var(--background-95); | ||||
|     color: var(--text-color-50); | ||||
|     font-size: 0.6rem; | ||||
|     height: 16px; | ||||
|     width: 100%; | ||||
|   } | ||||
|  | ||||
|   .shut-leave-to { | ||||
|     height: 0px; | ||||
|     transition: height 0.4s ease; | ||||
|     flex-wrap: no-wrap; | ||||
|     overflow: hidden; | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										125
									
								
								src/components/header/NavigationHeader.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,125 @@ | ||||
| <template> | ||||
|   <nav> | ||||
|     <!-- eslint-disable-next-line vuejs-accessibility/anchor-has-content --> | ||||
|     <a v-if="isHome" class="nav__logo" href="/"> | ||||
|       <TmdbLogo class="logo" /> | ||||
|     </a> | ||||
|  | ||||
|     <router-link v-else class="nav__logo" to="/" exact> | ||||
|       <TmdbLogo class="logo" /> | ||||
|     </router-link> | ||||
|  | ||||
|     <SearchInput /> | ||||
|  | ||||
|     <Hamburger class="mobile-only" /> | ||||
|     <NavigationIcon class="desktop-only" :route="profileRoute" /> | ||||
|  | ||||
|     <!-- <div class="navigation-icons-grid mobile-only" :class="{ open: isOpen }"> --> | ||||
|     <div v-if="isOpen" class="navigation-icons-grid mobile-only"> | ||||
|       <NavigationIcons> | ||||
|         <NavigationIcon :route="profileRoute" /> | ||||
|       </NavigationIcons> | ||||
|     </div> | ||||
|   </nav> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { computed } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import { useRoute } from "vue-router"; | ||||
|   import SearchInput from "@/components/header/SearchInput.vue"; | ||||
|   import Hamburger from "@/components/ui/Hamburger.vue"; | ||||
|   import NavigationIcons from "@/components/header/NavigationIcons.vue"; | ||||
|   import NavigationIcon from "@/components/header/NavigationIcon.vue"; | ||||
|   import TmdbLogo from "@/icons/tmdb-logo.vue"; | ||||
|   import IconProfile from "@/icons/IconProfile.vue"; | ||||
|   import IconProfileLock from "@/icons/IconProfileLock.vue"; | ||||
|   import type INavigationIcon from "../../interfaces/INavigationIcon"; | ||||
|  | ||||
|   const route = useRoute(); | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const signinNavigationIcon: INavigationIcon = { | ||||
|     title: "Signin", | ||||
|     route: "/signin", | ||||
|     icon: IconProfileLock | ||||
|   }; | ||||
|  | ||||
|   const profileNavigationIcon: INavigationIcon = { | ||||
|     title: "Profile", | ||||
|     route: "/profile", | ||||
|     icon: IconProfile | ||||
|   }; | ||||
|  | ||||
|   const isHome = computed(() => route.path === "/"); | ||||
|   const isOpen = computed(() => store.getters["hamburger/isOpen"]); | ||||
|   const loggedIn = computed(() => store.getters["user/loggedIn"]); | ||||
|  | ||||
|   const profileRoute = computed(() => | ||||
|     !loggedIn.value ? signinNavigationIcon : profileNavigationIcon | ||||
|   ); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
|   .spacer { | ||||
|     @include mobile-only { | ||||
|       width: 100%; | ||||
|       height: $header-size; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   nav { | ||||
|     display: grid; | ||||
|     grid-template-columns: var(--header-size) 1fr var(--header-size); | ||||
|  | ||||
|     > * { | ||||
|       z-index: 10; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .nav__logo { | ||||
|     overflow: hidden; | ||||
|   } | ||||
|  | ||||
|   .logo { | ||||
|     padding: 1rem; | ||||
|     fill: var(--color-green); | ||||
|     width: var(--header-size); | ||||
|     height: var(--header-size); | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     background: $background-nav-logo; | ||||
|     transition: transform 0.3s ease; | ||||
|  | ||||
|     &:hover { | ||||
|       transform: scale(1.08); | ||||
|     } | ||||
|  | ||||
|     @include mobile { | ||||
|       padding: 0.5rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .navigation-icons-grid { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     position: fixed; | ||||
|     top: var(--header-size); | ||||
|     left: 0; | ||||
|     width: 100%; | ||||
|     background-color: $background-95; | ||||
|     visibility: hidden; | ||||
|     opacity: 0; | ||||
|     transition: opacity 0.4s ease; | ||||
|  | ||||
|     opacity: 1; | ||||
|     visibility: visible; | ||||
|  | ||||
|     &.open { | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										97
									
								
								src/components/header/NavigationIcon.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,97 @@ | ||||
| <template> | ||||
|   <router-link | ||||
|     v-if="route?.requiresAuth == undefined || (route?.requiresAuth && loggedIn)" | ||||
|     :key="route?.title" | ||||
|     :to="{ path: route?.route }" | ||||
|   > | ||||
|     <li | ||||
|       class="navigation-link" | ||||
|       :class="{ active: matchesActiveRoute(), 'nofill-stroke': useStroke }" | ||||
|     > | ||||
|       <component :is="route.icon" class="navigation-icon"></component> | ||||
|       <span>{{ route.title }}</span> | ||||
|     </li> | ||||
|   </router-link> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { useStore } from "vuex"; | ||||
|   import { computed, defineProps } from "vue"; | ||||
|   import type INavigationIcon from "../../interfaces/INavigationIcon"; | ||||
|  | ||||
|   interface Props { | ||||
|     route: INavigationIcon; | ||||
|     active?: string; | ||||
|     useStroke?: boolean; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|  | ||||
|   const store = useStore(); | ||||
|   const loggedIn = computed(() => store.getters["user/loggedIn"]); | ||||
|  | ||||
|   function matchesActiveRoute() { | ||||
|     const currentRoute = props.route.title.toLowerCase(); | ||||
|     return props?.active?.includes(currentRoute); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
|   .navigation-link { | ||||
|     display: grid; | ||||
|     place-items: center; | ||||
|     min-height: var(--header-size); | ||||
|     list-style: none; | ||||
|     padding: 1rem 0.15rem; | ||||
|     text-align: center; | ||||
|     background-color: var(--background-color-secondary); | ||||
|     transition: transform 0.3s ease, color 0.3s ease, stoke 0.3s ease, | ||||
|       fill 0.3s ease, background-color 0.5s ease; | ||||
|  | ||||
|     &:hover { | ||||
|       transform: scale(1.05); | ||||
|     } | ||||
|  | ||||
|     &:hover, | ||||
|     &.active { | ||||
|       background-color: var(--background-color); | ||||
|  | ||||
|       span, | ||||
|       .navigation-icon { | ||||
|         color: var(--text-color); | ||||
|         fill: var(--text-color); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     span { | ||||
|       text-transform: uppercase; | ||||
|       font-size: 11px; | ||||
|       margin-top: 0.25rem; | ||||
|       color: var(--text-color-70); | ||||
|     } | ||||
|  | ||||
|     &.nofill-stroke { | ||||
|       .navigation-icon { | ||||
|         stroke: var(--text-color-70); | ||||
|         fill: none !important; | ||||
|       } | ||||
|  | ||||
|       &:hover .navigation-icon, | ||||
|       &.active .navigation-icon { | ||||
|         stroke: var(--text-color); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   a { | ||||
|     text-decoration: none; | ||||
|   } | ||||
|  | ||||
|   .navigation-icon { | ||||
|     width: 28px; | ||||
|     fill: var(--text-color-70); | ||||
|     transition: inherit; | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										99
									
								
								src/components/header/NavigationIcons.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,99 @@ | ||||
| <template> | ||||
|   <ul class="navigation-icons"> | ||||
|     <NavigationIcon | ||||
|       v-for="_route in routes" | ||||
|       :key="_route.route" | ||||
|       :route="_route" | ||||
|       :active="activeRoute" | ||||
|       :use-stroke="_route?.useStroke" | ||||
|     /> | ||||
|     <slot></slot> | ||||
|   </ul> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, watch } from "vue"; | ||||
|   import { useRoute } from "vue-router"; | ||||
|   import NavigationIcon from "@/components/header/NavigationIcon.vue"; | ||||
|   import IconInbox from "@/icons/IconInbox.vue"; | ||||
|   import IconNowPlaying from "@/icons/IconNowPlaying.vue"; | ||||
|   import IconPopular from "@/icons/IconPopular.vue"; | ||||
|   import IconUpcoming from "@/icons/IconUpcoming.vue"; | ||||
|   import IconSettings from "@/icons/IconSettings.vue"; | ||||
|   import IconActivity from "@/icons/IconActivity.vue"; | ||||
|   import IconBinoculars from "@/icons/IconBinoculars.vue"; | ||||
|   import type INavigationIcon from "../../interfaces/INavigationIcon"; | ||||
|  | ||||
|   const route = useRoute(); | ||||
|   const activeRoute = ref(window?.location?.pathname); | ||||
|   const routes: INavigationIcon[] = [ | ||||
|     { | ||||
|       title: "Requests", | ||||
|       route: "/list/requests", | ||||
|       icon: IconInbox | ||||
|     }, | ||||
|     { | ||||
|       title: "Now Playing", | ||||
|       route: "/list/now_playing", | ||||
|       icon: IconNowPlaying | ||||
|     }, | ||||
|     { | ||||
|       title: "Popular", | ||||
|       route: "/list/popular", | ||||
|       icon: IconPopular | ||||
|     }, | ||||
|     { | ||||
|       title: "Upcoming", | ||||
|       route: "/list/upcoming", | ||||
|       icon: IconUpcoming | ||||
|     }, | ||||
|     { | ||||
|       title: "Activity", | ||||
|       route: "/activity", | ||||
|       requiresAuth: true, | ||||
|       useStroke: true, | ||||
|       icon: IconActivity | ||||
|     }, | ||||
|     { | ||||
|       title: "Torrents", | ||||
|       route: "/torrents", | ||||
|       requiresAuth: true, | ||||
|       icon: IconBinoculars | ||||
|     }, | ||||
|     { | ||||
|       title: "Settings", | ||||
|       route: "/settings", | ||||
|       requiresAuth: true, | ||||
|       useStroke: true, | ||||
|       icon: IconSettings | ||||
|     } | ||||
|   ]; | ||||
|  | ||||
|   function setActiveRoute(_route: string) { | ||||
|     activeRoute.value = _route; | ||||
|   } | ||||
|  | ||||
|   watch(route, () => setActiveRoute(window?.location?.pathname || "")); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
|   .navigation-icons { | ||||
|     display: grid; | ||||
|     grid-column: 1fr; | ||||
|     padding-left: 0; | ||||
|     margin: 0; | ||||
|     background-color: var(--background-color-secondary); | ||||
|     z-index: 15; | ||||
|     width: 100%; | ||||
|  | ||||
|     @include desktop { | ||||
|       grid-template-rows: var(--header-size); | ||||
|     } | ||||
|  | ||||
|     @include mobile { | ||||
|       grid-template-columns: 1fr 1fr; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										290
									
								
								src/components/header/SearchInput.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,290 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="search" :class="{ active: inputIsActive }"> | ||||
|       <IconSearch class="search-icon" tabindex="-1" /> | ||||
|  | ||||
|       <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label --> | ||||
|       <input | ||||
|         ref="inputElement" | ||||
|         v-model="query" | ||||
|         type="text" | ||||
|         placeholder="Search for movie or show" | ||||
|         aria-label="Search input for finding a movie or show" | ||||
|         autocorrect="off" | ||||
|         autocapitalize="off" | ||||
|         tabindex="0" | ||||
|         @input="handleInput" | ||||
|         @click="focus" | ||||
|         @keydown.escape="handleEscape" | ||||
|         @keyup.enter="handleSubmit" | ||||
|         @keydown.up="navigateUp" | ||||
|         @keydown.down="navigateDown" | ||||
|         @focus="focus" | ||||
|         @blur="blur" | ||||
|       /> | ||||
|  | ||||
|       <IconClose | ||||
|         v-if="query && query.length" | ||||
|         tabindex="0" | ||||
|         aria-label="button" | ||||
|         class="close-icon" | ||||
|         @click="clearInput" | ||||
|         @keydown.enter.stop="clearInput" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <AutocompleteDropdown | ||||
|       v-if="showAutocompleteResults" | ||||
|       v-model:results="dropdownResults" | ||||
|       :query="query" | ||||
|       :index="dropdownIndex" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import { useRouter, useRoute } from "vue-router"; | ||||
|   import AutocompleteDropdown from "@/components/header/AutocompleteDropdown.vue"; | ||||
|   import IconSearch from "@/icons/IconSearch.vue"; | ||||
|   import IconClose from "@/icons/IconClose.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type { MediaTypes } from "../../interfaces/IList"; | ||||
|  | ||||
|   interface ISearchResult { | ||||
|     title: string; | ||||
|     id: number; | ||||
|     adult: boolean; | ||||
|     type: MediaTypes; | ||||
|   } | ||||
|  | ||||
|   const store = useStore(); | ||||
|   const router = useRouter(); | ||||
|   const route = useRoute(); | ||||
|  | ||||
|   const query: Ref<string> = ref(null); | ||||
|   const disabled: Ref<boolean> = ref(false); | ||||
|   const dropdownIndex: Ref<number> = ref(-1); | ||||
|   const dropdownResults: Ref<ISearchResult[]> = ref([]); | ||||
|   const inputIsActive: Ref<boolean> = ref(false); | ||||
|   const inputElement: Ref<HTMLInputElement> = ref(null); | ||||
|  | ||||
|   const isOpen = computed(() => store.getters["popup/isOpen"]); | ||||
|   const showAutocompleteResults = computed(() => { | ||||
|     return ( | ||||
|       !disabled.value && | ||||
|       inputIsActive.value && | ||||
|       query.value && | ||||
|       query.value.length > 0 | ||||
|     ); | ||||
|   }); | ||||
|  | ||||
|   const params = new URLSearchParams(window.location.search); | ||||
|   if (params && params.has("query")) { | ||||
|     query.value = decodeURIComponent(params.get("query")); | ||||
|   } | ||||
|  | ||||
|   const { ELASTIC } = process.env; | ||||
|   if (ELASTIC === undefined || ELASTIC === "") { | ||||
|     disabled.value = true; | ||||
|   } | ||||
|  | ||||
|   function navigateDown() { | ||||
|     if (dropdownIndex.value < dropdownResults.value.length - 1) { | ||||
|       dropdownIndex.value += 1; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function navigateUp() { | ||||
|     if (dropdownIndex.value > -1) dropdownIndex.value -= 1; | ||||
|  | ||||
|     const textLength = inputElement.value.value.length; | ||||
|  | ||||
|     setTimeout(() => { | ||||
|       inputElement.value.focus(); | ||||
|       inputElement.value.setSelectionRange(textLength, textLength + 1); | ||||
|     }, 1); | ||||
|   } | ||||
|  | ||||
|   function search() { | ||||
|     const encodedQuery = encodeURI(query.value.replace("/ /g", "+")); | ||||
|  | ||||
|     router.push({ | ||||
|       name: "search", | ||||
|       query: { | ||||
|         ...route.query, | ||||
|         query: encodedQuery | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   function handleInput() { | ||||
|     dropdownIndex.value = -1; | ||||
|   } | ||||
|  | ||||
|   function focus() { | ||||
|     inputIsActive.value = true; | ||||
|   } | ||||
|  | ||||
|   function reset() { | ||||
|     inputElement.value.blur(); | ||||
|     dropdownIndex.value = -1; | ||||
|     inputIsActive.value = false; | ||||
|   } | ||||
|  | ||||
|   function blur() { | ||||
|     return setTimeout(reset, 150); | ||||
|   } | ||||
|  | ||||
|   function clearInput() { | ||||
|     query.value = ""; | ||||
|     inputElement.value.focus(); | ||||
|   } | ||||
|  | ||||
|   function handleSubmit() { | ||||
|     if (!query.value || query.value.length === 0) return; | ||||
|  | ||||
|     if (dropdownIndex.value >= 0) { | ||||
|       const resultItem = dropdownResults.value[dropdownIndex.value]; | ||||
|  | ||||
|       store.dispatch("popup/open", { | ||||
|         id: resultItem?.id, | ||||
|         type: resultItem?.type | ||||
|       }); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     search(); | ||||
|     reset(); | ||||
|   } | ||||
|  | ||||
|   function handleEscape() { | ||||
|     if (!isOpen.value) reset(); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
|   .close-icon { | ||||
|     position: absolute; | ||||
|     top: calc(50% - 12px); | ||||
|     right: 0; | ||||
|     cursor: pointer; | ||||
|     fill: var(--text-color); | ||||
|     height: 24px; | ||||
|     width: 24px; | ||||
|  | ||||
|     @include tablet-min { | ||||
|       right: 6px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .filter { | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     margin: 1rem 2rem; | ||||
|  | ||||
|     h2 { | ||||
|       margin-top: 0.5rem; | ||||
|       margin-bottom: 0.5rem; | ||||
|       font-weight: 400; | ||||
|     } | ||||
|  | ||||
|     &-items { | ||||
|       display: flex; | ||||
|       flex-direction: row; | ||||
|       align-items: center; | ||||
|  | ||||
|       > :not(:first-child) { | ||||
|         margin-left: 1rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   hr { | ||||
|     display: block; | ||||
|     height: 1px; | ||||
|     border: 0; | ||||
|     border-bottom: 1px solid $text-color-50; | ||||
|     margin-top: 10px; | ||||
|     margin-bottom: 10px; | ||||
|     width: 90%; | ||||
|   } | ||||
|  | ||||
|   .search.active { | ||||
|     input { | ||||
|       border-color: var(--color-green); | ||||
|     } | ||||
|  | ||||
|     .search-icon { | ||||
|       fill: var(--color-green); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .search { | ||||
|     height: $header-size; | ||||
|     display: flex; | ||||
|     position: fixed; | ||||
|     flex-wrap: wrap; | ||||
|     z-index: 5; | ||||
|     border: 0; | ||||
|     background-color: $background-color-secondary; | ||||
|  | ||||
|     // TODO check if this is for mobile | ||||
|     width: calc(100% - 110px); | ||||
|     top: 0; | ||||
|     right: 55px; | ||||
|  | ||||
|     @include tablet-min { | ||||
|       position: relative; | ||||
|       width: 100%; | ||||
|       right: 0px; | ||||
|     } | ||||
|  | ||||
|     input { | ||||
|       display: block; | ||||
|       width: 100%; | ||||
|       padding: 13px 28px 13px 45px; | ||||
|       outline: none; | ||||
|       margin: 0; | ||||
|       border: 0; | ||||
|       background-color: $background-color-secondary; | ||||
|       font-weight: 300; | ||||
|       font-size: 18px; | ||||
|       color: $text-color; | ||||
|       border-bottom: 1px solid transparent; | ||||
|  | ||||
|       &:focus { | ||||
|         // border-bottom: 1px solid var(--color-green); | ||||
|         border-color: var(--color-green); | ||||
|       } | ||||
|  | ||||
|       @include tablet-min { | ||||
|         font-size: 24px; | ||||
|         padding: 13px 40px 13px 60px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &-icon { | ||||
|       width: 20px; | ||||
|       height: 20px; | ||||
|       fill: var(--text-color-50); | ||||
|       pointer-events: none; | ||||
|       position: absolute; | ||||
|       left: 15px; | ||||
|       top: calc(50% - 10px); | ||||
|  | ||||
|       @include tablet-min { | ||||
|         width: 24px; | ||||
|         height: 24px; | ||||
|         top: calc(50% - 12px); | ||||
|         left: 22px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										86
									
								
								src/components/popup/ActionButton.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,86 @@ | ||||
| <template> | ||||
|   <li | ||||
|     class="sidebar-list-element" | ||||
|     :class="{ active, disabled }" | ||||
|     @click="emit('click')" | ||||
|     @keydown.enter="emit('click')" | ||||
|   > | ||||
|     <slot></slot> | ||||
|   </li> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, defineEmits } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     active?: boolean; | ||||
|     disabled?: boolean; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "click"); | ||||
|   } | ||||
|  | ||||
|   const emit = defineEmits<Emit>(); | ||||
|   defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
|   li.sidebar-list-element { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     text-decoration: none; | ||||
|     text-transform: uppercase; | ||||
|     color: var(--text-color-50); | ||||
|     font-size: 12px; | ||||
|     padding: 10px 0; | ||||
|     border-bottom: 1px solid var(--text-color-5); | ||||
|     cursor: pointer; | ||||
|     user-select: none; | ||||
|     -webkit-user-select: none; | ||||
|  | ||||
|     &:first-of-type { | ||||
|       padding-top: 0; | ||||
|     } | ||||
|  | ||||
|     div > svg, | ||||
|     svg { | ||||
|       width: 26px; | ||||
|       height: 26px; | ||||
|       margin-right: 1rem; | ||||
|       transition: all 0.3s ease; | ||||
|       fill: var(--text-color-70); | ||||
|     } | ||||
|  | ||||
|     &:hover, | ||||
|     &.active { | ||||
|       color: var(--text-color); | ||||
|  | ||||
|       div > svg, | ||||
|       svg { | ||||
|         fill: var(--text-color); | ||||
|         transform: scale(1.1, 1.1); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.active > div > svg, | ||||
|     &.active > svg { | ||||
|       fill: var(--color-green); | ||||
|     } | ||||
|  | ||||
|     &.disabled { | ||||
|       cursor: default; | ||||
|     } | ||||
|  | ||||
|     .pending { | ||||
|       color: #f8bd2d; | ||||
|     } | ||||
|  | ||||
|     .meta { | ||||
|       margin-left: auto; | ||||
|       text-align: right; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										127
									
								
								src/components/popup/Description.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,127 @@ | ||||
| <template> | ||||
|   <div | ||||
|     ref="descriptionElement" | ||||
|     class="movie-description noselect" | ||||
|     @click="overflow ? (truncated = !truncated) : null" | ||||
|     @keydown.enter="overflow ? (truncated = !truncated) : null" | ||||
|   > | ||||
|     <span :class="{ truncated }">{{ description }}</span> | ||||
|  | ||||
|     <button v-if="description && overflow" class="truncate-toggle"> | ||||
|       <IconArrowDown :class="{ rotate: !truncated }" /> | ||||
|     </button> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps, onMounted } from "vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import IconArrowDown from "../../icons/IconArrowDown.vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     description: string; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const truncated: Ref<boolean> = ref(true); | ||||
|   const overflow: Ref<boolean> = ref(false); | ||||
|   const descriptionElement: Ref<HTMLElement> = ref(null); | ||||
|  | ||||
|   // eslint-disable-next-line no-undef | ||||
|   function removeElements(elems: NodeListOf<Element>) { | ||||
|     elems.forEach(el => el.remove()); | ||||
|   } | ||||
|  | ||||
|   // The description element overflows text after 4 rows with css | ||||
|   // line-clamp this takes the same text and adds to a temporary | ||||
|   // element without css overflow. If the temp element is | ||||
|   // higher then description element, we display expand button | ||||
|   function checkDescriptionOverflowing() { | ||||
|     const element = descriptionElement?.value; | ||||
|     if (!element) return; | ||||
|  | ||||
|     const { height, width } = element.getBoundingClientRect(); | ||||
|     const { fontSize, lineHeight } = getComputedStyle(element); | ||||
|  | ||||
|     const descriptionComparisonElement = document.createElement("div"); | ||||
|     descriptionComparisonElement.setAttribute( | ||||
|       "style", | ||||
|       `max-width: ${Math.ceil( | ||||
|         width + 10 | ||||
|       )}px; display: block; font-size: ${fontSize}; line-height: ${lineHeight};` | ||||
|     ); | ||||
|     // Don't know why need to add 10px to width, but works out perfectly | ||||
|  | ||||
|     descriptionComparisonElement.classList.add("dummy-non-overflow"); | ||||
|     descriptionComparisonElement.innerText = props.description; | ||||
|  | ||||
|     document.body.appendChild(descriptionComparisonElement); | ||||
|     const elemWithoutOverflowHeight = | ||||
|       descriptionComparisonElement.getBoundingClientRect().height; | ||||
|  | ||||
|     overflow.value = elemWithoutOverflowHeight > height; | ||||
|     removeElements(document.querySelectorAll(".dummy-non-overflow")); | ||||
|   } | ||||
|  | ||||
|   onMounted(checkDescriptionOverflowing); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
|   .movie-description { | ||||
|     font-weight: 300; | ||||
|     font-size: 13px; | ||||
|     line-height: 1.8; | ||||
|     margin-bottom: 20px; | ||||
|     transition: all 1s ease; | ||||
|  | ||||
|     @include tablet-min { | ||||
|       margin-bottom: 30px; | ||||
|       font-size: 14px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   span.truncated { | ||||
|     display: -webkit-box; | ||||
|     overflow: hidden; | ||||
|     -webkit-line-clamp: 4; | ||||
|     -webkit-box-orient: vertical; | ||||
|   } | ||||
|  | ||||
|   .truncate-toggle { | ||||
|     border: none; | ||||
|     background: none; | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     text-align: center; | ||||
|     color: var(--text-color); | ||||
|     margin-top: 1rem; | ||||
|     cursor: pointer; | ||||
|  | ||||
|     svg { | ||||
|       transition: 0.4s ease all; | ||||
|       height: 22px; | ||||
|       width: 22px; | ||||
|       fill: var(--text-color); | ||||
|  | ||||
|       &.rotate { | ||||
|         transform: rotateX(180deg); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &::before, | ||||
|     &::after { | ||||
|       content: ""; | ||||
|       flex: 1; | ||||
|       border-bottom: 1px solid var(--text-color-50); | ||||
|     } | ||||
|     &::before { | ||||
|       margin-right: 1rem; | ||||
|     } | ||||
|     &::after { | ||||
|       margin-left: 1rem; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										54
									
								
								src/components/popup/Detail.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | ||||
| <template> | ||||
|   <div class="movie-detail"> | ||||
|     <h2 class="title">{{ title }}</h2> | ||||
|     <span v-if="detail" class="info">{{ detail }}</span> | ||||
|  | ||||
|     <slot></slot> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     title: string; | ||||
|     detail?: string | number; | ||||
|   } | ||||
|  | ||||
|   defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
|   .movie-detail { | ||||
|     margin-bottom: 20px; | ||||
|  | ||||
|     &:last-of-type { | ||||
|       margin-bottom: 0px; | ||||
|     } | ||||
|  | ||||
|     @include tablet-min { | ||||
|       margin-bottom: 30px; | ||||
|     } | ||||
|  | ||||
|     h2.title { | ||||
|       margin: 0; | ||||
|       font-weight: 400; | ||||
|       text-transform: uppercase; | ||||
|       font-size: 1.2rem; | ||||
|       color: var(--color-green); | ||||
|  | ||||
|       @include mobile { | ||||
|         font-size: 1.1rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     span.info { | ||||
|       font-weight: 300; | ||||
|       font-size: 1rem; | ||||
|       letter-spacing: 0.8px; | ||||
|       margin-top: 5px; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										553
									
								
								src/components/popup/Movie.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,553 @@ | ||||
| <template> | ||||
|   <section class="movie"> | ||||
|     <!-- HEADER w/ POSTER --> | ||||
|     <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events --> | ||||
|     <header | ||||
|       ref="backdropElement" | ||||
|       :class="compact ? 'compact' : ''" | ||||
|       @click="compact = !compact" | ||||
|     > | ||||
|       <figure class="movie__poster"> | ||||
|         <img | ||||
|           ref="poster-image" | ||||
|           class="movie-item__img is-loaded" | ||||
|           alt="Movie poster" | ||||
|           :src="poster" | ||||
|         /> | ||||
|       </figure> | ||||
|  | ||||
|       <div v-if="media" class="movie__title"> | ||||
|         <h1>{{ media.title }}</h1> | ||||
|         <i>{{ media.tagline }}</i> | ||||
|       </div> | ||||
|       <loading-placeholder v-else :count="2" /> | ||||
|     </header> | ||||
|  | ||||
|     <!-- Siderbar and movie info --> | ||||
|     <div class="movie__main"> | ||||
|       <div class="movie__wrap movie__wrap--main"> | ||||
|         <!-- SIDEBAR ACTIONS --> | ||||
|         <div v-if="media" class="movie__actions"> | ||||
|           <action-button :active="media?.exists_in_plex" :disabled="true"> | ||||
|             <IconThumbsUp v-if="media?.exists_in_plex" /> | ||||
|             <IconThumbsDown v-else /> | ||||
|             {{ | ||||
|               !media?.exists_in_plex | ||||
|                 ? "Not yet available" | ||||
|                 : "Already available 🎉" | ||||
|             }} | ||||
|           </action-button> | ||||
|  | ||||
|           <action-button :active="requested" @click="sendRequest"> | ||||
|             <transition name="fade" mode="out-in"> | ||||
|               <div v-if="!requested" key="request"><IconRequest /></div> | ||||
|               <div v-else key="requested"><IconRequested /></div> | ||||
|             </transition> | ||||
|             {{ !requested ? `Request ${type}?` : "Already requested" }} | ||||
|           </action-button> | ||||
|  | ||||
|           <action-button | ||||
|             v-if="plexUserId && media?.exists_in_plex" | ||||
|             @click="openInPlex" | ||||
|           > | ||||
|             <IconPlay /> | ||||
|             Open and watch in plex now! | ||||
|           </action-button> | ||||
|  | ||||
|           <action-button | ||||
|             v-if="cast?.length" | ||||
|             :active="showCast" | ||||
|             @click="() => (showCast = !showCast)" | ||||
|           > | ||||
|             <IconProfile class="icon" /> | ||||
|             {{ showCast ? "Hide cast" : "Show cast" }} | ||||
|           </action-button> | ||||
|  | ||||
|           <action-button | ||||
|             v-if="admin === true" | ||||
|             :active="showTorrents" | ||||
|             @click="showTorrents = !showTorrents" | ||||
|           > | ||||
|             <IconBinoculars /> | ||||
|             Search for torrents | ||||
|             <span v-if="numberOfTorrentResults" class="meta">{{ | ||||
|               numberOfTorrentResults | ||||
|             }}</span> | ||||
|           </action-button> | ||||
|  | ||||
|           <action-button @click="openTmdb"> | ||||
|             <IconInfo /> | ||||
|             See more info | ||||
|           </action-button> | ||||
|         </div> | ||||
|  | ||||
|         <!-- Loading placeholder --> | ||||
|         <div v-else class="movie__actions text-input__loading"> | ||||
|           <div | ||||
|             v-for="index in admin ? Array(4) : Array(3)" | ||||
|             :key="index" | ||||
|             class="movie__actions-link" | ||||
|           > | ||||
|             <div | ||||
|               class="movie__actions-text text-input__loading--line" | ||||
|               style="margin: 9px; margin-left: -3px" | ||||
|             ></div> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <!-- MOVIE INFO --> | ||||
|         <div class="movie__info"> | ||||
|           <!-- Loading placeholder --> | ||||
|           <div v-if="loading"> | ||||
|             <loading-placeholder :count="5" /> | ||||
|           </div> | ||||
|  | ||||
|           <Description | ||||
|             v-if="!loading && media && media.overview" | ||||
|             :description="media.overview" | ||||
|           /> | ||||
|  | ||||
|           <div v-if="media" class="movie__details"> | ||||
|             <Detail | ||||
|               v-if="media.year" | ||||
|               title="Release date" | ||||
|               :detail="media.year" | ||||
|             /> | ||||
|             <Detail | ||||
|               v-if="media.type === MediaTypes.Movie && media.rating" | ||||
|               title="Rating" | ||||
|               :detail="media.rating" | ||||
|             /> | ||||
|             <Detail | ||||
|               v-if="media.type == MediaTypes.Show" | ||||
|               title="Seasons" | ||||
|               :detail="media.seasons" | ||||
|             /> | ||||
|             <Detail | ||||
|               v-if="media.genres && media.genres.length" | ||||
|               title="Genres" | ||||
|               :detail="media.genres.join(', ')" | ||||
|             /> | ||||
|             <Detail | ||||
|               v-if=" | ||||
|                 media.production_status && | ||||
|                 media.production_status !== 'Released' | ||||
|               " | ||||
|               title="Production status" | ||||
|               :detail="media.production_status" | ||||
|             /> | ||||
|             <Detail | ||||
|               v-if="media.runtime" | ||||
|               title="Runtime" | ||||
|               :detail="humanMinutes(media.runtime)" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <!-- TODO: change this classname, this is general  --> | ||||
|  | ||||
|         <div v-if="showCast && cast?.length" class="movie__admin"> | ||||
|           <Detail title="cast"> | ||||
|             <CastList :cast="cast" /> | ||||
|           </Detail> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <!-- TORRENT LIST --> | ||||
|       <TorrentList | ||||
|         v-if="media && admin && showTorrents" | ||||
|         class="torrents" | ||||
|         :query="media?.title" | ||||
|         :tmdb-id="id" | ||||
|       ></TorrentList> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineProps, onMounted } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|  | ||||
|   // import img from "@/directives/v-image"; | ||||
|   import IconProfile from "@/icons/IconProfile.vue"; | ||||
|   import IconThumbsUp from "@/icons/IconThumbsUp.vue"; | ||||
|   import IconThumbsDown from "@/icons/IconThumbsDown.vue"; | ||||
|   import IconInfo from "@/icons/IconInfo.vue"; | ||||
|   import IconRequest from "@/icons/IconRequest.vue"; | ||||
|   import IconRequested from "@/icons/IconRequested.vue"; | ||||
|   import IconBinoculars from "@/icons/IconBinoculars.vue"; | ||||
|   import IconPlay from "@/icons/IconPlay.vue"; | ||||
|   import TorrentList from "@/components/torrent/TruncatedTorrentResults.vue"; | ||||
|   import CastList from "@/components/CastList.vue"; | ||||
|   import Detail from "@/components/popup/Detail.vue"; | ||||
|   import ActionButton from "@/components/popup/ActionButton.vue"; | ||||
|   import Description from "@/components/popup/Description.vue"; | ||||
|   import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import type { | ||||
|     IMovie, | ||||
|     IShow, | ||||
|     IMediaCredits, | ||||
|     ICast | ||||
|   } from "../../interfaces/IList"; | ||||
|   import type { | ||||
|     IRequestStatusResponse, | ||||
|     IRequestSubmitResponse | ||||
|   } from "../../interfaces/IRequestResponse"; | ||||
|   import { MediaTypes } from "../../interfaces/IList"; | ||||
|  | ||||
|   import { humanMinutes } from "../../utils"; | ||||
|   import { | ||||
|     getMovie, | ||||
|     getShow, | ||||
|     getMovieCredits, | ||||
|     getShowCredits, | ||||
|     request, | ||||
|     getRequestStatus | ||||
|     // watchLink | ||||
|   } from "../../api"; | ||||
|  | ||||
|   interface Props { | ||||
|     id: number; | ||||
|     type: MediaTypes.Movie | MediaTypes.Show; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const ASSET_URL = "https://image.tmdb.org/t/p/"; | ||||
|   const ASSET_SIZES = ["w500", "w780", "original"]; | ||||
|  | ||||
|   const media: Ref<IMovie | IShow> = ref(); | ||||
|   const requested: Ref<boolean> = ref(); | ||||
|   const showTorrents: Ref<boolean> = ref(); | ||||
|   const showCast: Ref<boolean> = ref(); | ||||
|   const cast: Ref<ICast[]> = ref([]); | ||||
|   const compact: Ref<boolean> = ref(); | ||||
|   const loading: Ref<boolean> = ref(); | ||||
|   const backdropElement: Ref<HTMLElement> = ref(); | ||||
|  | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const admin = computed(() => store.getters["user/admin"]); | ||||
|   const plexUserId = computed(() => store.getters["user/plexUserId"]); | ||||
|  | ||||
|   const poster = computed(() => { | ||||
|     if (!media.value) return "/assets/placeholder.png"; | ||||
|     if (!media.value?.poster) return "/assets/no-image.svg"; | ||||
|  | ||||
|     return `${ASSET_URL}${ASSET_SIZES[0]}${media.value.poster}`; | ||||
|   }); | ||||
|  | ||||
|   const numberOfTorrentResults = computed(() => { | ||||
|     const count = store.getters["torrentModule/resultCount"]; | ||||
|     return count ? `${count} results` : null; | ||||
|   }); | ||||
|  | ||||
|   function setCast(_cast: ICast[]) { | ||||
|     cast.value = _cast; | ||||
|   } | ||||
|   function setRequested( | ||||
|     requestResponse: IRequestStatusResponse | IRequestSubmitResponse | ||||
|   ) { | ||||
|     if (requestResponse?.success) { | ||||
|       requested.value = requestResponse?.success; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     requested.value = false; | ||||
|   } | ||||
|  | ||||
|   function setBackdrop(): void { | ||||
|     if ( | ||||
|       !media.value?.backdrop || | ||||
|       !backdropElement.value?.style || | ||||
|       backdropElement.value?.style?.backgroundImage !== "" | ||||
|     ) | ||||
|       return; | ||||
|  | ||||
|     const backdropURL = `${ASSET_URL}${ASSET_SIZES[1]}${media.value.backdrop}`; | ||||
|     backdropElement.value.style.backgroundImage = `url(${backdropURL})`; | ||||
|   } | ||||
|  | ||||
|   function getCredits( | ||||
|     type: MediaTypes.Movie | MediaTypes.Show | ||||
|   ): Promise<IMediaCredits> { | ||||
|     if (type === MediaTypes.Movie) { | ||||
|       return getMovieCredits(props.id); | ||||
|     } | ||||
|     if (type === MediaTypes.Show) { | ||||
|       return getShowCredits(props.id); | ||||
|     } | ||||
|  | ||||
|     return Promise.reject(); | ||||
|   } | ||||
|  | ||||
|   function setAndReturnMedia(_media: IMovie | IShow) { | ||||
|     media.value = _media; | ||||
|     return _media; | ||||
|   } | ||||
|  | ||||
|   function fetchMedia() { | ||||
|     if (!props.id || !props.type) { | ||||
|       console.error("Unable to fetch media, requires id & type"); // eslint-disable-line no-console | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     let apiFunction: typeof getMovie; | ||||
|     let parameters: { | ||||
|       checkExistance: boolean; | ||||
|       credits: boolean; | ||||
|       releaseDates?: boolean; | ||||
|     }; | ||||
|  | ||||
|     if (props.type === MediaTypes.Movie) { | ||||
|       apiFunction = getMovie; | ||||
|       parameters = { checkExistance: true, credits: false }; | ||||
|     } else if (props.type === MediaTypes.Show) { | ||||
|       apiFunction = getShow; | ||||
|       parameters = { checkExistance: true, credits: false }; | ||||
|     } | ||||
|  | ||||
|     apiFunction(props.id, { ...parameters }) | ||||
|       .then(setAndReturnMedia) | ||||
|       .then(() => getCredits(props.type)) | ||||
|       .then(credits => setCast(credits?.cast || [])) | ||||
|       .then(() => getRequestStatus(props.id, props.type)) | ||||
|       .then(requestResponse => setRequested(requestResponse)) | ||||
|       .then(setBackdrop); | ||||
|   } | ||||
|  | ||||
|   function sendRequest() { | ||||
|     request(props.id, props.type).then(requestResponse => | ||||
|       setRequested(requestResponse) | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function openInPlex(): boolean { | ||||
|     // watchLink() | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   function openTmdb() { | ||||
|     const tmdbType = props.type === MediaTypes.Show ? "tv" : props.type; | ||||
|     const tmdbURL = `https://www.themoviedb.org/${tmdbType}/${props.id}`; | ||||
|     window.location.href = tmdbURL; | ||||
|   } | ||||
|  | ||||
|   // On created functions | ||||
|   fetchMedia(); | ||||
|   store.dispatch("torrentModule/setResultCount", null); | ||||
|   // End on create functions | ||||
|  | ||||
|   onMounted(setBackdrop); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/loading-placeholder"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
|   header { | ||||
|     $duration: 0.2s; | ||||
|     transform: scaleY(1); | ||||
|     transition: height $duration ease; | ||||
|     transform-origin: top; | ||||
|     position: relative; | ||||
|     background-size: cover; | ||||
|     background-repeat: no-repeat; | ||||
|     background-position: 50% 50%; | ||||
|     background-color: $background-color; | ||||
|     display: grid; | ||||
|     grid-template-columns: 1fr 1fr; | ||||
|     height: 350px; | ||||
|  | ||||
|     @include mobile { | ||||
|       grid-template-columns: 1fr; | ||||
|       height: 250px; | ||||
|       place-items: center; | ||||
|     } | ||||
|  | ||||
|     * { | ||||
|       z-index: 2; | ||||
|     } | ||||
|  | ||||
|     &::before { | ||||
|       content: ""; | ||||
|       display: block; | ||||
|       position: absolute; | ||||
|       top: 0; | ||||
|       left: 0; | ||||
|       z-index: 1; | ||||
|       width: 100%; | ||||
|       height: 100%; | ||||
|       background: $background-dark-85; | ||||
|     } | ||||
|  | ||||
|     @include mobile { | ||||
|       &.compact { | ||||
|         height: 100px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .movie__poster { | ||||
|     display: none; | ||||
|  | ||||
|     @include desktop { | ||||
|       background: var(--background-color); | ||||
|       height: auto; | ||||
|       display: block; | ||||
|       width: calc(100% - 80px); | ||||
|       margin: 40px; | ||||
|  | ||||
|       > img { | ||||
|         width: 100%; | ||||
|         border-radius: 10px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .movie { | ||||
|     &__wrap { | ||||
|       &--header { | ||||
|         align-items: center; | ||||
|         height: 100%; | ||||
|       } | ||||
|       &--main { | ||||
|         display: flex; | ||||
|         flex-wrap: wrap; | ||||
|         flex-direction: column; | ||||
|         @include tablet-min { | ||||
|           flex-direction: row; | ||||
|         } | ||||
|  | ||||
|         background-color: $background-color; | ||||
|         color: $text-color; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__img { | ||||
|       display: block; | ||||
|       width: 100%; | ||||
|       opacity: 0; | ||||
|       transform: scale(0.97) translateZ(0); | ||||
|       transition: opacity 0.5s ease, transform 0.5s ease; | ||||
|  | ||||
|       &.is-loaded { | ||||
|         opacity: 1; | ||||
|         transform: scale(1); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__title { | ||||
|       position: relative; | ||||
|       padding: 20px; | ||||
|       text-align: center; | ||||
|       width: 100%; | ||||
|       height: fit-content; | ||||
|  | ||||
|       @include tablet-min { | ||||
|         text-align: left; | ||||
|         padding: 140px 30px 0 40px; | ||||
|       } | ||||
|       h1 { | ||||
|         color: var(--color-green); | ||||
|         font-weight: 500; | ||||
|         line-height: 1.4; | ||||
|         font-size: 24px; | ||||
|         margin-bottom: 0; | ||||
|  | ||||
|         @include tablet-min { | ||||
|           font-size: 30px; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       i { | ||||
|         display: block; | ||||
|         color: rgba(255, 255, 255, 0.8); | ||||
|         margin-top: 1rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__actions { | ||||
|       text-align: center; | ||||
|       width: 100%; | ||||
|       order: 2; | ||||
|       padding: 20px; | ||||
|       border-top: 1px solid $text-color-5; | ||||
|       @include tablet-min { | ||||
|         order: 1; | ||||
|         width: 45%; | ||||
|         padding: 185px 0 40px 40px; | ||||
|         border-top: 0; | ||||
|       } | ||||
|     } | ||||
|     &__info { | ||||
|       width: 100%; | ||||
|       padding: 20px; | ||||
|       order: 1; | ||||
|       @include tablet-min { | ||||
|         order: 2; | ||||
|         padding: 40px; | ||||
|         width: 55%; | ||||
|         margin-left: 45%; | ||||
|       } | ||||
|     } | ||||
|     &__info { | ||||
|       margin-left: 0; | ||||
|     } | ||||
|     &__details { | ||||
|       display: flex; | ||||
|       flex-wrap: wrap; | ||||
|  | ||||
|       > * { | ||||
|         margin-right: 30px; | ||||
|  | ||||
|         @include mobile { | ||||
|           margin-right: 20px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     &__admin { | ||||
|       width: 100%; | ||||
|       padding: 20px; | ||||
|       order: 2; | ||||
|       @include tablet-min { | ||||
|         order: 3; | ||||
|         padding: 40px; | ||||
|         padding-top: 0px; | ||||
|         width: 100%; | ||||
|       } | ||||
|       &-title { | ||||
|         margin: 0; | ||||
|         font-weight: 400; | ||||
|         text-transform: uppercase; | ||||
|         text-align: center; | ||||
|         font-size: 14px; | ||||
|         color: $green; | ||||
|         padding-bottom: 20px; | ||||
|         @include tablet-min { | ||||
|           font-size: 16px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .torrents { | ||||
|       background-color: var(--background-color); | ||||
|       padding: 0 1rem; | ||||
|  | ||||
|       @include mobile { | ||||
|         padding: 0 0.5rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .fade-enter-active, | ||||
|   .fade-leave-active { | ||||
|     transition: opacity 0.4s; | ||||
|   } | ||||
|   .fade-enter, | ||||
|   .fade-leave-to { | ||||
|     opacity: 0; | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										278
									
								
								src/components/popup/Person.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,278 @@ | ||||
| <template> | ||||
|   <section class="person"> | ||||
|     <header ref="header"> | ||||
|       <div class="info"> | ||||
|         <h1 v-if="person"> | ||||
|           {{ person.name }} | ||||
|         </h1> | ||||
|         <div v-else> | ||||
|           <loading-placeholder :count="1" /> | ||||
|           <loading-placeholder :count="1" line-class="short" :top="3.5" /> | ||||
|         </div> | ||||
|  | ||||
|         <span v-if="person && person['known_for_department']" class="known-for"> | ||||
|           {{ | ||||
|             person.known_for_department === "Acting" | ||||
|               ? "Actor" | ||||
|               : person.known_for_department | ||||
|           }} | ||||
|         </span> | ||||
|       </div> | ||||
|  | ||||
|       <figure class="person__poster"> | ||||
|         <img | ||||
|           ref="poster-image" | ||||
|           class="person-item__img is-loaded" | ||||
|           alt="Image of person" | ||||
|           :src="poster" | ||||
|         /> | ||||
|       </figure> | ||||
|     </header> | ||||
|  | ||||
|     <div v-if="loading"> | ||||
|       <loading-placeholder :count="6" /> | ||||
|       <loading-placeholder line-class="short" :top="3" /> | ||||
|       <loading-placeholder :count="6" line-class="fullwidth" /> | ||||
|       <loading-placeholder line-class="short" :top="4.5" /> | ||||
|       <loading-placeholder /> | ||||
|     </div> | ||||
|  | ||||
|     <div v-if="person"> | ||||
|       <Detail v-if="age" title="Age" :detail="age" /> | ||||
|  | ||||
|       <Detail | ||||
|         v-if="person" | ||||
|         title="Born" | ||||
|         :detail="person.place_of_birth ? person.place_of_birth : '(Not found)'" | ||||
|       /> | ||||
|  | ||||
|       <Detail v-if="person.biography" title="Biography"> | ||||
|         <Description :description="person.biography" /> | ||||
|       </Detail> | ||||
|  | ||||
|       <Detail | ||||
|         v-if="creditedShows.length" | ||||
|         title="movies" | ||||
|         :detail="`Credited in ${creditedMovies.length} movies`" | ||||
|       > | ||||
|         <CastList :cast="creditedMovies" /> | ||||
|       </Detail> | ||||
|  | ||||
|       <Detail | ||||
|         v-if="creditedShows.length" | ||||
|         title="shows" | ||||
|         :detail="`Credited in ${creditedShows.length} shows`" | ||||
|       > | ||||
|         <CastList :cast="creditedShows" /> | ||||
|       </Detail> | ||||
|     </div> | ||||
|   </section> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineProps } from "vue"; | ||||
|   import CastList from "@/components/CastList.vue"; | ||||
|   import Detail from "@/components/popup/Detail.vue"; | ||||
|   import Description from "@/components/popup/Description.vue"; | ||||
|   import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue"; | ||||
|   import type { Ref, ComputedRef } from "vue"; | ||||
|   import { getPerson, getPersonCredits } from "../../api"; | ||||
|   import type { | ||||
|     IPerson, | ||||
|     IPersonCredits, | ||||
|     IMovie, | ||||
|     IShow | ||||
|   } from "../../interfaces/IList"; | ||||
|   import { MediaTypes } from "../../interfaces/IList"; | ||||
|  | ||||
|   interface Props { | ||||
|     id: number; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const ASSET_URL = "https://image.tmdb.org/t/p/"; | ||||
|   const ASSET_SIZES = ["w500", "w780", "original"]; | ||||
|  | ||||
|   const person: Ref<IPerson> = ref(); | ||||
|   const credits: Ref<IPersonCredits> = ref(); | ||||
|   const loading: Ref<boolean> = ref(false); | ||||
|   const creditedMovies: Ref<Array<IMovie | IShow>> = ref([]); | ||||
|   const creditedShows: Ref<Array<IMovie | IShow>> = ref([]); | ||||
|  | ||||
|   const poster: ComputedRef<string> = computed(() => { | ||||
|     if (!person.value) return "/assets/placeholder.png"; | ||||
|     if (!person.value?.poster) return "/assets/no-image.svg"; | ||||
|  | ||||
|     return `${ASSET_URL}${ASSET_SIZES[0]}${person.value.poster}`; | ||||
|   }); | ||||
|  | ||||
|   const age: ComputedRef<string> = computed(() => { | ||||
|     if (!person.value?.birthday) return ""; | ||||
|  | ||||
|     const today = new Date().getFullYear(); | ||||
|     const birthYear = new Date(person.value.birthday).getFullYear(); | ||||
|     return `${today - birthYear} years old`; | ||||
|   }); | ||||
|  | ||||
|   function setCredits(_credits: IPersonCredits) { | ||||
|     credits.value = _credits; | ||||
|   } | ||||
|  | ||||
|   function setPerson(_person: IPerson) { | ||||
|     person.value = _person; | ||||
|   } | ||||
|  | ||||
|   function sortPopularity(a: IMovie | IShow, b: IMovie | IShow): number { | ||||
|     return a.popularity < b.popularity ? 1 : -1; | ||||
|   } | ||||
|  | ||||
|   function alreadyExists( | ||||
|     item: IMovie | IShow, | ||||
|     pos: number, | ||||
|     self: Array<IMovie | IShow> | ||||
|   ) { | ||||
|     const names = self.map(_item => _item.title); | ||||
|     return names.indexOf(item.title) === pos; | ||||
|   } | ||||
|  | ||||
|   function personCreditedFrom(cast: Array<IMovie | IShow>): void { | ||||
|     creditedMovies.value = cast | ||||
|       .filter(credit => credit.type === MediaTypes.Movie) | ||||
|       .filter(alreadyExists) | ||||
|       .sort(sortPopularity); | ||||
|  | ||||
|     creditedShows.value = cast | ||||
|       .filter(credit => credit.type === MediaTypes.Show) | ||||
|       .filter(alreadyExists) | ||||
|       .sort(sortPopularity); | ||||
|   } | ||||
|  | ||||
|   function fetchPerson() { | ||||
|     if (!props.id) { | ||||
|       console.error("Unable to fetch person, missing id!"); // eslint-disable-line no-console | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     getPerson(props.id) | ||||
|       .then(setPerson) | ||||
|       .then(() => getPersonCredits(person.value?.id)) | ||||
|       .then(setCredits) | ||||
|       .then(() => personCreditedFrom(credits.value?.cast)); | ||||
|   } | ||||
|  | ||||
|   // On create functions | ||||
|   fetchPerson(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/loading-placeholder"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/main"; | ||||
|  | ||||
|   section.person { | ||||
|     overflow: hidden; | ||||
|     position: relative; | ||||
|     padding: 40px; | ||||
|     background-color: var(--background-color); | ||||
|  | ||||
|     @include mobile { | ||||
|       padding: 50px 20px 10px; | ||||
|     } | ||||
|  | ||||
|     &:before { | ||||
|       content: ""; | ||||
|       display: block; | ||||
|       position: absolute; | ||||
|       top: -130px; | ||||
|       left: -100px; | ||||
|       z-index: 1; | ||||
|       width: 1000px; | ||||
|       height: 500px; | ||||
|       transform: rotate(21deg); | ||||
|       background-color: #062541; | ||||
|  | ||||
|       @include mobile { | ||||
|         // top: -52vw; | ||||
|         top: -215px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   header { | ||||
|     $duration: 0.2s; | ||||
|     transition: height $duration ease; | ||||
|     position: relative; | ||||
|     background-color: transparent; | ||||
|     display: grid; | ||||
|     grid-template-columns: 1fr 1fr; | ||||
|     height: 350px; | ||||
|     z-index: 2; | ||||
|  | ||||
|     @include mobile { | ||||
|       height: 180px; | ||||
|     } | ||||
|  | ||||
|     .info { | ||||
|       display: flex; | ||||
|       flex-direction: column; | ||||
|       padding: 30px; | ||||
|       padding-left: 0; | ||||
|       text-align: left; | ||||
|  | ||||
|       @include mobile { | ||||
|         padding: 0; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     h1 { | ||||
|       color: $green; | ||||
|       width: 100%; | ||||
|       font-weight: 500; | ||||
|       line-height: 1.4; | ||||
|       font-size: 30px; | ||||
|       margin-top: 0; | ||||
|  | ||||
|       @include mobile { | ||||
|         font-size: 24px; | ||||
|         margin: 10px 0; | ||||
|         // padding: 30px 30px 30px 40px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .known-for { | ||||
|       color: rgba(255, 255, 255, 0.8); | ||||
|       font-size: 1.2rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .person__poster { | ||||
|     display: block; | ||||
|     margin: auto; | ||||
|     width: fit-content; | ||||
|     border-radius: 10px; | ||||
|     background-color: grey; | ||||
|     animation: pulse 1s infinite ease-in-out; | ||||
|  | ||||
|     @keyframes pulse { | ||||
|       0% { | ||||
|         background-color: rgba(165, 165, 165, 0.1); | ||||
|       } | ||||
|       50% { | ||||
|         background-color: rgba(165, 165, 165, 0.3); | ||||
|       } | ||||
|       100% { | ||||
|         background-color: rgba(165, 165, 165, 0.1); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     > img { | ||||
|       border-radius: 10px; | ||||
|       width: 100%; | ||||
|  | ||||
|       @include mobile { | ||||
|         max-width: 225px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										98
									
								
								src/components/profile/ChangePassword.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,98 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <h3 class="settings__header">Change password</h3> | ||||
|     <form class="form"> | ||||
|       <seasoned-input | ||||
|         v-model="oldPassword" | ||||
|         placeholder="old password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-input | ||||
|         v-model="newPassword" | ||||
|         placeholder="new password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-input | ||||
|         v-model="newPasswordRepeat" | ||||
|         placeholder="repeat new password" | ||||
|         icon="Keyhole" | ||||
|         type="password" | ||||
|       /> | ||||
|  | ||||
|       <seasoned-button @click="changePassword">change password</seasoned-button> | ||||
|     </form> | ||||
|     <seasoned-messages v-model:messages="messages" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref } from "vue"; | ||||
|   import SeasonedInput from "@/components/ui/SeasonedInput.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { ErrorMessageTypes } from "../../interfaces/IErrorMessage"; | ||||
|   import type { IErrorMessage } from "../../interfaces/IErrorMessage"; | ||||
|  | ||||
|   // interface ResetPasswordPayload { | ||||
|   //   old_password: string; | ||||
|   //   new_password: string; | ||||
|   // } | ||||
|  | ||||
|   const oldPassword: Ref<string> = ref(""); | ||||
|   const newPassword: Ref<string> = ref(""); | ||||
|   const newPasswordRepeat: Ref<string> = ref(""); | ||||
|   const messages: Ref<IErrorMessage[]> = ref([]); | ||||
|  | ||||
|   function addWarningMessage(message: string, title?: string) { | ||||
|     messages.value.push({ | ||||
|       message, | ||||
|       title, | ||||
|       type: ErrorMessageTypes.Warning | ||||
|     } as IErrorMessage); | ||||
|   } | ||||
|  | ||||
|   function validate() { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       if (!oldPassword.value || oldPassword?.value?.length === 0) { | ||||
|         addWarningMessage("Missing old password!", "Validation error"); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       if (!newPassword.value || newPassword?.value?.length === 0) { | ||||
|         addWarningMessage("Missing new password!", "Validation error"); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       if (newPassword.value !== newPasswordRepeat.value) { | ||||
|         addWarningMessage( | ||||
|           "Password and password repeat do not match!", | ||||
|           "Validation error" | ||||
|         ); | ||||
|         reject(); | ||||
|       } | ||||
|  | ||||
|       resolve(true); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // TODO seasoned-api /user/password-reset | ||||
|   async function changePassword() { | ||||
|     try { | ||||
|       validate(); | ||||
|     } catch (error) { | ||||
|       console.log("not valid!"); // eslint-disable-line no-console | ||||
|     } | ||||
|  | ||||
|     // const body: ResetPasswordPayload = { | ||||
|     //   old_password: oldPassword.value, | ||||
|     //   new_password: newPassword.value | ||||
|     // }; | ||||
|     // const options = {}; | ||||
|     // fetch() | ||||
|   } | ||||
| </script> | ||||
							
								
								
									
										114
									
								
								src/components/profile/LinkPlexAccount.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,114 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <h3 class="settings__header">Plex account</h3> | ||||
|  | ||||
|     <div v-if="!plexUserId"> | ||||
|       <span class="info" | ||||
|         >Sign in to your plex account to get information about recently added | ||||
|         movies and to see your watch history</span | ||||
|       > | ||||
|  | ||||
|       <form class="form"> | ||||
|         <seasoned-input | ||||
|           v-model="username" | ||||
|           placeholder="plex username" | ||||
|           type="email" | ||||
|         /> | ||||
|         <seasoned-input | ||||
|           v-model="password" | ||||
|           placeholder="plex password" | ||||
|           type="password" | ||||
|           @enter="authenticatePlex" | ||||
|         > | ||||
|         </seasoned-input> | ||||
|  | ||||
|         <seasoned-button @click="authenticatePlex" | ||||
|           >link plex account</seasoned-button | ||||
|         > | ||||
|       </form> | ||||
|     </div> | ||||
|  | ||||
|     <div v-else> | ||||
|       <span class="info" | ||||
|         >Awesome, your account is already authenticated with plex! Enjoy viewing | ||||
|         your seasoned search history, plex watch history and real-time torrent | ||||
|         download progress.</span | ||||
|       > | ||||
|       <seasoned-button @click="unauthenticatePlex" | ||||
|         >un-link plex account</seasoned-button | ||||
|       > | ||||
|     </div> | ||||
|     <seasoned-messages v-model:messages="messages" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineEmits } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import seasonedInput from "@/components/ui/SeasonedInput.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import SeasonedMessages from "@/components/ui/SeasonedMessages.vue"; | ||||
|   import type { Ref, ComputedRef } from "vue"; | ||||
|   import { linkPlexAccount, unlinkPlexAccount } from "../../api"; | ||||
|   import { ErrorMessageTypes } from "../../interfaces/IErrorMessage"; | ||||
|   import type { IErrorMessage } from "../../interfaces/IErrorMessage"; | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "reload"); | ||||
|   } | ||||
|  | ||||
|   const username: Ref<string> = ref(""); | ||||
|   const password: Ref<string> = ref(""); | ||||
|   const messages: Ref<IErrorMessage[]> = ref([]); | ||||
|  | ||||
|   const store = useStore(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   const plexUserId: ComputedRef<boolean> = computed( | ||||
|     () => store.getters["user/plexUserId"] | ||||
|   ); | ||||
|  | ||||
|   async function authenticatePlex() { | ||||
|     const { success, message } = await linkPlexAccount( | ||||
|       username.value, | ||||
|       password.value | ||||
|     ); | ||||
|  | ||||
|     if (success) { | ||||
|       username.value = ""; | ||||
|       password.value = ""; | ||||
|       emit("reload"); | ||||
|     } | ||||
|  | ||||
|     messages.value.push({ | ||||
|       type: success ? ErrorMessageTypes.Success : ErrorMessageTypes.Error, | ||||
|       title: success ? "Authenticated with plex" : "Something went wrong", | ||||
|       message | ||||
|     } as IErrorMessage); | ||||
|   } | ||||
|  | ||||
|   async function unauthenticatePlex() { | ||||
|     const response = await unlinkPlexAccount(); | ||||
|  | ||||
|     if (response?.success) { | ||||
|       emit("reload"); | ||||
|     } | ||||
|  | ||||
|     messages.value.push({ | ||||
|       type: response.success | ||||
|         ? ErrorMessageTypes.Success | ||||
|         : ErrorMessageTypes.Error, | ||||
|       title: response.success | ||||
|         ? "Unlinked plex account " | ||||
|         : "Something went wrong", | ||||
|       message: response.message | ||||
|     } as IErrorMessage); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   .info { | ||||
|     display: block; | ||||
|     margin-bottom: 25px; | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										6
									
								
								src/components/torrent/ActiveTorrents.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | ||||
| <template> | ||||
|   <code | ||||
|     >Monitor active torrents requested. Requires authentication with owners plex | ||||
|     library!</code | ||||
|   > | ||||
| </template> | ||||
							
								
								
									
										162
									
								
								src/components/torrent/TorrentSearchResults.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,162 @@ | ||||
| <template> | ||||
|   <div v-if="query?.length" class="container"> | ||||
|     <h2 class="torrent-header-text"> | ||||
|       Searching for: <span class="query">{{ query }}</span> | ||||
|     </h2> | ||||
|  | ||||
|     <loader v-if="loading" type="section" /> | ||||
|     <div v-else> | ||||
|       <div v-if="torrents.length > 0" class="torrent-table"> | ||||
|         <torrent-table :torrents="torrents" @magnet="addTorrent" /> | ||||
|  | ||||
|         <slot /> | ||||
|       </div> | ||||
|  | ||||
|       <div v-else class="no-results"> | ||||
|         <h2>No results found</h2> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, inject, defineProps } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|   import Loader from "@/components/ui/Loader.vue"; | ||||
|   import TorrentTable from "@/components/torrent/TorrentTable.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { searchTorrents, addMagnet } from "../../api"; | ||||
|   import type ITorrent from "../../interfaces/ITorrent"; | ||||
|  | ||||
|   interface Props { | ||||
|     query: string; | ||||
|     tmdbId?: number; | ||||
|   } | ||||
|  | ||||
|   const loading: Ref<boolean> = ref(true); | ||||
|   const torrents: Ref<ITorrent[]> = ref([]); | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const store = useStore(); | ||||
|   const notifications: { | ||||
|     info; | ||||
|     success; | ||||
|     error; | ||||
|   } = inject("notifications"); | ||||
|  | ||||
|   function setTorrents(_torrents: ITorrent[]) { | ||||
|     torrents.value = _torrents || []; | ||||
|   } | ||||
|  | ||||
|   function setLoading(state: boolean) { | ||||
|     loading.value = state; | ||||
|   } | ||||
|  | ||||
|   function updateResultCountDisplay() { | ||||
|     store.dispatch("torrentModule/setResults", torrents.value); | ||||
|     store.dispatch( | ||||
|       "torrentModule/setResultCount", | ||||
|       torrents.value?.length || -1 | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   function fetchTorrents() { | ||||
|     if (!props.query?.length) return; | ||||
|  | ||||
|     loading.value = true; | ||||
|     searchTorrents(props.query) | ||||
|       .then(torrentResponse => setTorrents(torrentResponse?.results)) | ||||
|       .then(() => updateResultCountDisplay()) | ||||
|       .finally(() => setLoading(false)); | ||||
|   } | ||||
|  | ||||
|   function addTorrent(torrent: ITorrent) { | ||||
|     const { name, magnet } = torrent; | ||||
|  | ||||
|     notifications.info({ | ||||
|       title: "Adding torrent 🧲", | ||||
|       description: props.query, | ||||
|       timeout: 3000 | ||||
|     }); | ||||
|  | ||||
|     addMagnet(magnet, name, props.tmdbId) | ||||
|       .then(() => { | ||||
|         notifications.success({ | ||||
|           title: "Torrent added 🎉", | ||||
|           description: props.query, | ||||
|           timeout: 3000 | ||||
|         }); | ||||
|       }) | ||||
|       .catch(() => { | ||||
|         notifications.error({ | ||||
|           title: "Failed to add torrent 🙅♀️", | ||||
|           description: "Check console for more info", | ||||
|           timeout: 3000 | ||||
|         }); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   fetchTorrents(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/elements"; | ||||
|  | ||||
|   h2 { | ||||
|     font-size: 20px; | ||||
|   } | ||||
|  | ||||
|   .toggle { | ||||
|     max-width: unset !important; | ||||
|     margin: 1rem 0; | ||||
|   } | ||||
|  | ||||
|   .container { | ||||
|     background-color: $background-color; | ||||
|   } | ||||
|  | ||||
|   .no-results { | ||||
|     display: flex; | ||||
|     padding-bottom: 2rem; | ||||
|     justify-content: center; | ||||
|     flex-direction: column; | ||||
|     width: 100%; | ||||
|     align-items: center; | ||||
|   } | ||||
|  | ||||
|   .torrent-header-text { | ||||
|     font-weight: 300; | ||||
|     text-transform: uppercase; | ||||
|     font-size: 20px; | ||||
|     color: var(--text-color); | ||||
|     text-align: center; | ||||
|     margin: 0; | ||||
|  | ||||
|     .query { | ||||
|       font-weight: 500; | ||||
|       white-space: pre; | ||||
|     } | ||||
|  | ||||
|     @include mobile { | ||||
|       text-align: left; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .download { | ||||
|     &__icon { | ||||
|       fill: $text-color-70; | ||||
|       height: 1.2rem; | ||||
|  | ||||
|       &:hover { | ||||
|         fill: $text-color; | ||||
|         cursor: pointer; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.active &__icon { | ||||
|       fill: $green; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										269
									
								
								src/components/torrent/TorrentTable.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,269 @@ | ||||
| <template> | ||||
|   <table> | ||||
|     <thead class="table__header noselect"> | ||||
|       <tr> | ||||
|         <th | ||||
|           v-for="column in columns" | ||||
|           :key="column" | ||||
|           :class="column === selectedColumn ? 'active' : null" | ||||
|           @click="sortTable(column)" | ||||
|         > | ||||
|           {{ column }} | ||||
|           <span v-if="prevCol === column && direction">↑</span> | ||||
|           <span v-if="prevCol === column && !direction">↓</span> | ||||
|         </th> | ||||
|       </tr> | ||||
|     </thead> | ||||
|  | ||||
|     <tbody> | ||||
|       <tr | ||||
|         v-for="torrent in torrents" | ||||
|         :key="torrent.magnet" | ||||
|         class="table__content" | ||||
|       > | ||||
|         <td | ||||
|           @click="expand($event, torrent.name)" | ||||
|           @keydown.enter="expand($event, torrent.name)" | ||||
|         > | ||||
|           {{ torrent.name }} | ||||
|         </td> | ||||
|         <td | ||||
|           @click="expand($event, torrent.name)" | ||||
|           @keydown.enter="expand($event, torrent.name)" | ||||
|         > | ||||
|           {{ torrent.seed }} | ||||
|         </td> | ||||
|         <td | ||||
|           @click="expand($event, torrent.name)" | ||||
|           @keydown.enter="expand($event, torrent.name)" | ||||
|         > | ||||
|           {{ torrent.size }} | ||||
|         </td> | ||||
|         <td | ||||
|           class="download" | ||||
|           @click="() => emit('magnet', torrent)" | ||||
|           @keydown.enter="() => emit('magnet', torrent)" | ||||
|         > | ||||
|           <IconMagnet /> | ||||
|         </td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps, defineEmits } from "vue"; | ||||
|   import IconMagnet from "@/icons/IconMagnet.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|   import { sortableSize } from "../../utils"; | ||||
|   import type ITorrent from "../../interfaces/ITorrent"; | ||||
|  | ||||
|   interface Props { | ||||
|     torrents: Array<ITorrent>; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "magnet", torrent: ITorrent): void; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   const columns: string[] = ["name", "seed", "size", "add"]; | ||||
|  | ||||
|   const torrents: Ref<ITorrent[]> = ref(props.torrents); | ||||
|   const direction: Ref<boolean> = ref(false); | ||||
|   const selectedColumn: Ref<string> = ref(columns[0]); | ||||
|   const prevCol: Ref<string> = ref(""); | ||||
|  | ||||
|   function expand(event: MouseEvent, text: string) { | ||||
|     const elementClicked = event.target as HTMLElement; | ||||
|     const tableRow = elementClicked.parentElement; | ||||
|     const scopedStyleDataVariable = Object.keys(tableRow.dataset)[0]; | ||||
|  | ||||
|     const existingExpandedElement = | ||||
|       document.getElementsByClassName("expanded")[0]; | ||||
|     const clickedSameTwice = | ||||
|       existingExpandedElement?.previousSibling?.isEqualNode(tableRow); | ||||
|  | ||||
|     if (existingExpandedElement) { | ||||
|       existingExpandedElement.remove(); | ||||
|  | ||||
|       // Clicked the same element twice, remove and return | ||||
|       // not recreate and collapse | ||||
|       if (clickedSameTwice) return; | ||||
|     } | ||||
|  | ||||
|     const expandedRow = document.createElement("tr"); | ||||
|     const expandedCol = document.createElement("td"); | ||||
|     expandedRow.dataset[scopedStyleDataVariable] = ""; | ||||
|     expandedCol.dataset[scopedStyleDataVariable] = ""; | ||||
|     expandedRow.className = "expanded"; | ||||
|     expandedCol.innerText = text; | ||||
|     expandedCol.colSpan = 4; | ||||
|  | ||||
|     expandedRow.appendChild(expandedCol); | ||||
|     tableRow.insertAdjacentElement("afterend", expandedRow); | ||||
|   } | ||||
|  | ||||
|   function sortName() { | ||||
|     const torrentsCopy = [...torrents.value]; | ||||
|     if (direction.value) { | ||||
|       torrents.value = torrentsCopy.sort((a, b) => (a.name < b.name ? 1 : -1)); | ||||
|     } else { | ||||
|       torrents.value = torrentsCopy.sort((a, b) => (a.name > b.name ? 1 : -1)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function sortSeed() { | ||||
|     const torrentsCopy = [...torrents.value]; | ||||
|     if (direction.value) { | ||||
|       torrents.value = torrentsCopy.sort( | ||||
|         (a, b) => parseInt(a.seed, 10) - parseInt(b.seed, 10) | ||||
|       ); | ||||
|     } else { | ||||
|       torrents.value = torrentsCopy.sort( | ||||
|         (a, b) => parseInt(b.seed, 10) - parseInt(a.seed, 10) | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function sortSize() { | ||||
|     const torrentsCopy = [...torrents.value]; | ||||
|     if (direction.value) { | ||||
|       torrents.value = torrentsCopy.sort( | ||||
|         (a, b) => sortableSize(a.size) - sortableSize(b.size) | ||||
|       ); | ||||
|     } else { | ||||
|       torrents.value = torrentsCopy.sort( | ||||
|         (a, b) => sortableSize(b.size) - sortableSize(a.size) | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function sortTable(col, sameDirection = false) { | ||||
|     if (prevCol.value === col && sameDirection === false) { | ||||
|       direction.value = !direction.value; | ||||
|     } | ||||
|  | ||||
|     if (col === "name") sortName(); | ||||
|     else if (col === "seed") sortSeed(); | ||||
|     else if (col === "size") sortSize(); | ||||
|  | ||||
|     prevCol.value = col; | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|   @import "src/scss/elements"; | ||||
|  | ||||
|   table { | ||||
|     border-spacing: 0; | ||||
|     margin-top: 0.5rem; | ||||
|     width: 100%; | ||||
|     // border-collapse: collapse; | ||||
|     border-radius: 0.5rem; | ||||
|     overflow: hidden; | ||||
|   } | ||||
|  | ||||
|   th, | ||||
|   td { | ||||
|     border: 0.5px solid var(--background-color-40); | ||||
|     @include mobile { | ||||
|       white-space: nowrap; | ||||
|       padding: 0; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   thead { | ||||
|     position: relative; | ||||
|     user-select: none; | ||||
|     -webkit-user-select: none; | ||||
|     color: var(--table-header-text-color); | ||||
|     text-transform: uppercase; | ||||
|     cursor: pointer; | ||||
|     background-color: var(--table-background-color); | ||||
|     // background-color: black; | ||||
|     // color: var(--color-green); | ||||
|     letter-spacing: 0.8px; | ||||
|     font-size: 1rem; | ||||
|  | ||||
|     th:last-of-type { | ||||
|       padding-right: 0.4rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   tbody { | ||||
|     // first column | ||||
|     tr td:first-of-type { | ||||
|       position: relative; | ||||
|       padding: 0 0.3rem; | ||||
|       cursor: default; | ||||
|       word-break: break-all; | ||||
|       border-left: 1px solid var(--table-background-color); | ||||
|  | ||||
|       @include mobile { | ||||
|         max-width: 40vw; | ||||
|         overflow-x: hidden; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // all columns except first | ||||
|     tr td:not(td:first-of-type) { | ||||
|       text-align: center; | ||||
|       white-space: nowrap; | ||||
|     } | ||||
|  | ||||
|     // last column | ||||
|     tr td:last-of-type { | ||||
|       vertical-align: middle; | ||||
|       cursor: pointer; | ||||
|       border-right: 1px solid var(--table-background-color); | ||||
|  | ||||
|       svg { | ||||
|         width: 21px; | ||||
|         display: block; | ||||
|         margin: auto; | ||||
|         padding: 0.3rem 0; | ||||
|         fill: var(--text-color); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // alternate background color per row | ||||
|     tr:nth-child(even) { | ||||
|       background-color: var(--background-70); | ||||
|     } | ||||
|  | ||||
|     // last element rounded corner border | ||||
|     tr:last-of-type { | ||||
|       td { | ||||
|         border-bottom: 1px solid var(--table-background-color); | ||||
|       } | ||||
|  | ||||
|       td:first-of-type { | ||||
|         border-bottom-left-radius: 0.5rem; | ||||
|       } | ||||
|  | ||||
|       td:last-of-type { | ||||
|         border-bottom-right-radius: 0.5rem; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .expanded { | ||||
|     padding: 0.25rem 1rem; | ||||
|     max-width: 100%; | ||||
|     border-left: 1px solid $text-color; | ||||
|     border-right: 1px solid $text-color; | ||||
|     border-bottom: 1px solid $text-color; | ||||
|  | ||||
|     td { | ||||
|       white-space: normal; | ||||
|       word-break: break-all; | ||||
|       padding: 0.5rem 0.15rem; | ||||
|       width: 100%; | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										88
									
								
								src/components/torrent/TruncatedTorrentResults.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,88 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <torrent-search-results | ||||
|       :query="query" | ||||
|       :tmdb-id="tmdbId" | ||||
|       :class="{ truncated: truncated }" | ||||
|       ><div | ||||
|         v-if="truncated" | ||||
|         class="load-more" | ||||
|         tabindex="0" | ||||
|         role="button" | ||||
|         @click="truncated = false" | ||||
|         @keydown.enter="truncated = false" | ||||
|       > | ||||
|         <icon-arrow-down /> | ||||
|       </div> | ||||
|     </torrent-search-results> | ||||
|  | ||||
|     <div class="edit-query-btn-container"> | ||||
|       <seasonedButton @click="openInTorrentPage" | ||||
|         >View on torrent page</seasonedButton | ||||
|       > | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, defineProps } from "vue"; | ||||
|   import { useRouter } from "vue-router"; | ||||
|   import TorrentSearchResults from "@/components/torrent/TorrentSearchResults.vue"; | ||||
|   import SeasonedButton from "@/components/ui/SeasonedButton.vue"; | ||||
|   import IconArrowDown from "@/icons/IconArrowDown.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     query: string; | ||||
|     tmdbId?: number; | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const router = useRouter(); | ||||
|  | ||||
|   const truncated: Ref<boolean> = ref(true); | ||||
|  | ||||
|   function openInTorrentPage() { | ||||
|     if (!props.query?.length) { | ||||
|       router.push("/torrents"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     router.push({ path: "/torrents", query: { query: props.query } }); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   :global(.truncated .torrent-table) { | ||||
|     position: relative; | ||||
|     max-height: 500px; | ||||
|     overflow-y: hidden; | ||||
|   } | ||||
|  | ||||
|   .load-more { | ||||
|     position: absolute; | ||||
|     display: flex; | ||||
|     align-items: flex-end; | ||||
|     justify-content: center; | ||||
|     bottom: 0rem; | ||||
|     width: 100%; | ||||
|     height: 3rem; | ||||
|     cursor: pointer; | ||||
|     background: linear-gradient( | ||||
|       to top, | ||||
|       var(--background-color) 20%, | ||||
|       var(--background-0) 100% | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   svg { | ||||
|     height: 30px; | ||||
|     fill: var(--text-color); | ||||
|   } | ||||
|  | ||||
|   .edit-query-btn-container { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     padding: 1rem; | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										48
									
								
								src/components/ui/DarkmodeToggle.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,48 @@ | ||||
| <template> | ||||
|   <div class="darkToggle"> | ||||
|     <span @click="toggleDarkmode" @keydown.enter="toggleDarkmode">{{ | ||||
|       darkmodeToggleIcon | ||||
|     }}</span> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed } from "vue"; | ||||
|  | ||||
|   function systemDarkModeEnabled() { | ||||
|     const computedStyle = window.getComputedStyle(document.body); | ||||
|     if (computedStyle?.colorScheme != null) { | ||||
|       return computedStyle.colorScheme.includes("dark"); | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   const darkmode = ref(systemDarkModeEnabled()); | ||||
|   const darkmodeToggleIcon = computed(() => { | ||||
|     return darkmode.value ? "🌝" : "🌚"; | ||||
|   }); | ||||
|  | ||||
|   function toggleDarkmode() { | ||||
|     darkmode.value = !darkmode.value; | ||||
|     document.body.className = darkmode.value ? "dark" : "light"; | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   .darkToggle { | ||||
|     height: 25px; | ||||
|     width: 25px; | ||||
|     cursor: pointer; | ||||
|     position: fixed; | ||||
|     margin-bottom: 1.5rem; | ||||
|     margin-right: 2px; | ||||
|     bottom: 0; | ||||
|     right: 0; | ||||
|     z-index: 10; | ||||
|  | ||||
|     -webkit-user-select: none; | ||||
|     -moz-user-select: none; | ||||
|     -ms-user-select: none; | ||||
|     user-select: none; | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										85
									
								
								src/components/ui/Hamburger.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,85 @@ | ||||
| <template> | ||||
|   <div | ||||
|     class="nav__hamburger" | ||||
|     :class="{ open: isOpen }" | ||||
|     tabindex="0" | ||||
|     @click="toggle" | ||||
|     @keydown.enter="toggle" | ||||
|   > | ||||
|     <div v-for="(_, index) in 3" :key="index" class="bar"></div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { computed } from "vue"; | ||||
|   import { useStore } from "vuex"; | ||||
|  | ||||
|   const store = useStore(); | ||||
|  | ||||
|   const isOpen = computed(() => store.getters["hamburger/isOpen"]); | ||||
|   const toggle = () => { | ||||
|     store.dispatch("hamburger/toggle"); | ||||
|   }; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
|   .nav__hamburger { | ||||
|     display: block; | ||||
|     position: relative; | ||||
|     width: var(--header-size); | ||||
|     height: var(--header-size); | ||||
|     cursor: pointer; | ||||
|     border-left: 1px solid var(--background-color); | ||||
|     background-color: var(--background-color-secondary); | ||||
|  | ||||
|     @include tablet-min { | ||||
|       display: none; | ||||
|     } | ||||
|  | ||||
|     .bar { | ||||
|       position: absolute; | ||||
|       width: 23px; | ||||
|       height: 1px; | ||||
|       background-color: var(--text-color-70); | ||||
|       transition: all 300ms ease; | ||||
|       &:nth-child(1) { | ||||
|         left: 16px; | ||||
|         top: 17px; | ||||
|       } | ||||
|       &:nth-child(2) { | ||||
|         left: 16px; | ||||
|         top: 25px; | ||||
|         &:after { | ||||
|           content: ""; | ||||
|           position: absolute; | ||||
|           left: 0px; | ||||
|           top: 0px; | ||||
|           width: 23px; | ||||
|           height: 1px; | ||||
|           transition: all 300ms ease; | ||||
|         } | ||||
|       } | ||||
|       &:nth-child(3) { | ||||
|         right: 15px; | ||||
|         top: 33px; | ||||
|       } | ||||
|     } | ||||
|     &.open { | ||||
|       .bar { | ||||
|         &:nth-child(1), | ||||
|         &:nth-child(3) { | ||||
|           width: 0; | ||||
|         } | ||||
|         &:nth-child(2) { | ||||
|           transform: rotate(-45deg); | ||||
|         } | ||||
|         &:nth-child(2):after { | ||||
|           transform: rotate(-90deg); | ||||
|           background-color: var(--text-color-70); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
| @@ -1,46 +1,68 @@ | ||||
| <template> | ||||
|   <div class="loader"> | ||||
|   <div :class="`loader type-${type || LoaderHeightType.Page}`"> | ||||
|     <i class="loader--icon"> | ||||
|       <i class="loader--icon-spinner" /> | ||||
|     </i> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
|   <!-- | ||||
|   TODO: fetch and display movie facts after 1.5 seconds while loading? | ||||
|    | ||||
|  | ||||
| --></template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|   import LoaderHeightType from "../../interfaces/ILoader"; | ||||
|  | ||||
|   interface Props { | ||||
|     type?: LoaderHeightType; | ||||
|   } | ||||
|  | ||||
|   defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
|   @import "src/scss/variables"; | ||||
|  | ||||
| .loader { | ||||
|   display: flex; | ||||
|   width: 100%; | ||||
|   height: 30vh; | ||||
|   justify-content: center; | ||||
|   align-items: center; | ||||
|   .loader { | ||||
|     display: flex; | ||||
|     width: 100%; | ||||
|     height: 30vh; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|  | ||||
|   &--icon{ | ||||
|     border: 2px solid $text-color-70; | ||||
|     border-radius: 50%; | ||||
|     display: block; | ||||
|     height: 40px; | ||||
|     position: absolute; | ||||
|     width: 40px; | ||||
|     &.type-section { | ||||
|       height: 15vh; | ||||
|     } | ||||
|  | ||||
|     &-spinner { | ||||
|     &--icon { | ||||
|       border: 2px solid $text-color-70; | ||||
|       border-radius: 50%; | ||||
|       display: block; | ||||
|       animation: load 1s linear infinite; | ||||
|       height: 35px; | ||||
|       width: 35px; | ||||
|       &:after { | ||||
|         border: 7px solid $green-90; | ||||
|         border-radius: 50%; | ||||
|         content: ''; | ||||
|         left: 8px; | ||||
|         position: absolute; | ||||
|         top: 22px; | ||||
|       height: 40px; | ||||
|       position: absolute; | ||||
|       width: 40px; | ||||
|  | ||||
|       &-spinner { | ||||
|         display: block; | ||||
|         animation: load 1s linear infinite; | ||||
|         height: 35px; | ||||
|         width: 35px; | ||||
|         &:after { | ||||
|           border: 7px solid $green-90; | ||||
|           border-radius: 50%; | ||||
|           content: ""; | ||||
|           left: 8px; | ||||
|           position: absolute; | ||||
|           top: 22px; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     @keyframes load { | ||||
|       100% { | ||||
|         transform: rotate(360deg); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   @keyframes load { | ||||
|     100% { transform: rotate(360deg); } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,27 +1,26 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="text-input__loading"> | ||||
|       <div class="text-input__loading--line" :class="lineClass" v-for="_ in Array(count)"></div> | ||||
|     </div> | ||||
|   <div class="text-input__loading" :style="`margin-top: ${top || 0}rem`"> | ||||
|     <div | ||||
|       v-for="l in Array(count || 1)" | ||||
|       :key="l" | ||||
|       class="text-input__loading--line" | ||||
|       :class="lineClass || ''" | ||||
|     ></div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     count: { | ||||
|       type: Number, | ||||
|       require: true | ||||
|     }, | ||||
|     lineClass: { | ||||
|       type: String, | ||||
|       default: '' | ||||
|     } | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     count?: number; | ||||
|     lineClass?: string; | ||||
|     top?: number; | ||||
|   } | ||||
| } | ||||
|  | ||||
|   defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/loading-placeholder"; | ||||
|  | ||||
|   @import "src/scss/loading-placeholder"; | ||||
| </style> | ||||
| @@ -1,54 +1,77 @@ | ||||
| <template> | ||||
|   <div class="seasoned-button"> | ||||
|     <button type="button" class="button" @click="emit('click')" :class="{ active: active }"><slot></slot></button> | ||||
|   </div> | ||||
|   <button | ||||
|     type="button" | ||||
|     :class="{ active: active, fullwidth: fullWidth }" | ||||
|     @click="emit('click')" | ||||
|   > | ||||
|     <slot></slot> | ||||
|   </button> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, defineEmits } from "vue"; | ||||
|  | ||||
| export default { | ||||
|   name: 'seasonedButton', | ||||
|   props: { | ||||
|     active: Boolean | ||||
|   }, | ||||
|   methods: { | ||||
|     emit() { | ||||
|       this.$emit('click') | ||||
|     } | ||||
|   interface Props { | ||||
|     active?: boolean; | ||||
|     fullWidth?: boolean; | ||||
|   } | ||||
| } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "click"); | ||||
|   } | ||||
|  | ||||
|   defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .button{ | ||||
|   display: inline-block; | ||||
|   border: 1px solid $text-color; | ||||
|   text-transform: uppercase; | ||||
|   font-weight: 300; | ||||
|   font-size: 11px; | ||||
|   line-height: 2; | ||||
|   height: 45px; | ||||
|   letter-spacing: 0.5px; | ||||
|   padding: 5px 20px 4px 20px; | ||||
|   margin: 0; | ||||
|   margin-right: 0.3rem; | ||||
|   cursor: pointer; | ||||
|   color: $text-color; | ||||
|   background: $background-color-secondary; | ||||
|   outline: none; | ||||
|   transition: background 0.5s ease, color 0.5s ease, border-color .5s ease; | ||||
|   button { | ||||
|     display: inline-block; | ||||
|     border: 1px solid $text-color; | ||||
|     font-size: 11px; | ||||
|     font-weight: 300; | ||||
|     line-height: 1.5; | ||||
|     letter-spacing: 0.5px; | ||||
|     text-transform: uppercase; | ||||
|     min-height: 45px; | ||||
|     padding: 5px 10px 4px 10px; | ||||
|     margin: 0; | ||||
|     margin-right: 0.3rem; | ||||
|     color: $text-color; | ||||
|     background: $background-color-secondary; | ||||
|     cursor: pointer; | ||||
|     outline: none; | ||||
|     transition: background 0.5s ease, color 0.5s ease, border-color 0.5s ease; | ||||
|  | ||||
|   @include tablet-min{ | ||||
|     font-size: 12px; | ||||
|     padding: 6px 20px 5px 20px; | ||||
|     @include desktop { | ||||
|       font-size: 0.8rem; | ||||
|       padding: 6px 20px 5px 20px; | ||||
|     } | ||||
|  | ||||
|     &.fullwidth { | ||||
|       font-size: 14px; | ||||
|       width: 40%; | ||||
|  | ||||
|       @include mobile { | ||||
|         width: 60%; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:focus, | ||||
|     &:active, | ||||
|     &.active { | ||||
|       background: $text-color; | ||||
|       color: $background-color; | ||||
|     } | ||||
|  | ||||
|     @media (hover: hover) { | ||||
|       &:hover { | ||||
|         background: $text-color; | ||||
|         color: $background-color; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   body:not(.touch) &:hover, &:focus, &:active, &.active { | ||||
|     background: $text-color; | ||||
|     color: $background-color; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,116 +1,140 @@ | ||||
| <template> | ||||
|   <div class="group" :class="{ completed: value }"> | ||||
|     <svg class="group__input-icon"><use v-bind="{'xlink:href':'#icon' + icon}"></use></svg> | ||||
|     <input class="group__input" :type="tempType || type" @input="handleInput" v-model="inputValue" | ||||
|           :placeholder="placeholder" @keyup.enter="submit" /> | ||||
|   <div class="group" :class="{ completed: modelValue, focus }"> | ||||
|     <component :is="inputIcon" v-if="inputIcon" /> | ||||
|  | ||||
|     <i v-if="value && type === 'password'" @click="toggleShowPassword" class="group__input-show noselect">show</i> | ||||
|     <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label --> | ||||
|     <input | ||||
|       class="input" | ||||
|       :type="toggledType || type || 'text'" | ||||
|       :placeholder="placeholder" | ||||
|       :value="modelValue" | ||||
|       @input="handleInput" | ||||
|       @keyup.enter="event => emit('enter', event)" | ||||
|       @focus="focus = true" | ||||
|       @blur="focus = false" | ||||
|     /> | ||||
|  | ||||
|     <i | ||||
|       v-if="modelValue && type === 'password'" | ||||
|       class="show noselect" | ||||
|       tabindex="0" | ||||
|       @click="toggleShowPassword" | ||||
|       @keydown.enter="toggleShowPassword" | ||||
|       >{{ toggledType == "password" ? "show" : "hide" }}</i | ||||
|     > | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     placeholder: { type: String }, | ||||
|     icon: { type: String }, | ||||
|     type: { type: String, default: 'text' }, | ||||
|     value: { type: String, default: undefined } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       inputValue: this.value || undefined, | ||||
|       tempType: undefined | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     submit(event) { | ||||
|       this.$emit('enter') | ||||
|     }, | ||||
|     handleInput(event) { | ||||
|       if (this.value !== undefined) { | ||||
|         this.$emit('update:value', this.inputValue) | ||||
|       } else { | ||||
|         this.$emit('change', this.inputValue, event) | ||||
|       } | ||||
|     }, | ||||
|     toggleShowPassword() { | ||||
|       if (this.tempType === 'text') { | ||||
|         this.tempType = 'password' | ||||
|       } else { | ||||
|         this.tempType = 'text' | ||||
|       } | ||||
| <script setup lang="ts"> | ||||
|   import { ref, computed, defineProps, defineEmits } from "vue"; | ||||
|   import IconKey from "@/icons/IconKey.vue"; | ||||
|   import IconEmail from "@/icons/IconEmail.vue"; | ||||
|   import IconBinoculars from "@/icons/IconBinoculars.vue"; | ||||
|   import type { Ref } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     modelValue: string; | ||||
|     placeholder: string; | ||||
|     type?: string; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "change"); | ||||
|     (e: "enter", event?: KeyboardEvent); | ||||
|     (e: "update:modelValue", value: string); | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   const toggledType: Ref<string> = ref(props.type); | ||||
|   const focus: Ref<boolean> = ref(false); | ||||
|  | ||||
|   const inputIcon = computed(() => { | ||||
|     if (props.type === "password") return IconKey; | ||||
|     if (props.type === "email") return IconEmail; | ||||
|     if (props.type === "torrents") return IconBinoculars; | ||||
|     return false; | ||||
|   }); | ||||
|  | ||||
|   function handleInput(event: KeyboardEvent) { | ||||
|     const target = event?.target as HTMLInputElement; | ||||
|     if (!target) return; | ||||
|  | ||||
|     emit("update:modelValue", target?.value); | ||||
|   } | ||||
|  | ||||
|   // Could we move this to component that injects ?? | ||||
|   function toggleShowPassword() { | ||||
|     if (toggledType.value === "text") { | ||||
|       toggledType.value = "password"; | ||||
|     } else { | ||||
|       toggledType.value = "text"; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .group{ | ||||
|   display: flex; | ||||
|   margin-bottom: 1rem; | ||||
|   width: 100%; | ||||
|  | ||||
|   &:hover, &:focus { | ||||
|     .group__input { | ||||
|       border-color: $text-color; | ||||
|  | ||||
|       &-icon { | ||||
|         fill: $text-color; | ||||
|       }       | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.completed { | ||||
|     .group__input { | ||||
|       border-color: $text-color; | ||||
|  | ||||
|       &-icon { | ||||
|         fill: $text-color; | ||||
|       }       | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__input { | ||||
|   .group { | ||||
|     display: flex; | ||||
|     width: 100%; | ||||
|     position: relative; | ||||
|     max-width: 35rem; | ||||
|     padding: 10px 10px 10px 45px; | ||||
|     outline: none; | ||||
|     background-color: $background-color-secondary; | ||||
|     color: $text-color; | ||||
|     font-weight: 100; | ||||
|     font-size: 1.2rem; | ||||
|     border: 1px solid $text-color-50; | ||||
|     margin: 0; | ||||
|     margin-left: -2.2rem !important; | ||||
|     z-index: 3; | ||||
|     transition: color .5s ease, background-color .5s ease, border .5s ease; | ||||
|     border: 1px solid var(--text-color-50); | ||||
|     background-color: var(--background-color-secondary); | ||||
|  | ||||
|     border-radius: 0; | ||||
|     -webkit-appearance: none; | ||||
|     &.completed, | ||||
|     &.focus, | ||||
|     &:hover, | ||||
|     &:focus { | ||||
|       border-color: var(--text-color); | ||||
|  | ||||
|     &-show { | ||||
|       position: relative; | ||||
|       left: -50px; | ||||
|       svg { | ||||
|         fill: var(--text-color); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     svg { | ||||
|       width: 24px; | ||||
|       height: 24px; | ||||
|       fill: var(--text-color-50); | ||||
|       pointer-events: none; | ||||
|       margin-top: 10px; | ||||
|       margin-left: 10px; | ||||
|       z-index: 8; | ||||
|     } | ||||
|  | ||||
|     input { | ||||
|       width: 100%; | ||||
|       padding: 10px; | ||||
|       outline: none; | ||||
|       background-color: var(--background-color-secondary); | ||||
|       color: var(--text-color); | ||||
|       font-weight: 100; | ||||
|       font-size: 1.2rem; | ||||
|       margin: 0; | ||||
|       z-index: 3; | ||||
|       border: none; | ||||
|  | ||||
|       border-radius: 0; | ||||
|       -webkit-appearance: none; | ||||
|     } | ||||
|  | ||||
|     .show { | ||||
|       position: absolute; | ||||
|       display: grid; | ||||
|       place-items: center; | ||||
|       right: 20px; | ||||
|       z-index: 11; | ||||
|       margin: auto 0; | ||||
|       height: 100%; | ||||
|       font-size: 0.9rem; | ||||
|       cursor: pointer; | ||||
|       color: $text-color-50; | ||||
|       color: var(--text-color-50); | ||||
|       -webkit-user-select: none; | ||||
|       user-select: none; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &__input-icon { | ||||
|     width: 24px; | ||||
|     height: 24px; | ||||
|     fill: $text-color-50; | ||||
|     transition: fill 0.5s ease; | ||||
|     pointer-events: none; | ||||
|     margin-top: 10px; | ||||
|     margin-left: 10px; | ||||
|     z-index: 8; | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,153 +1,173 @@ | ||||
| <template> | ||||
|   <transition-group name="fade"> | ||||
|     <div class="message" v-for="(message, index) in reversedMessages" :class="message.type || 'warning'" :key="index"> | ||||
|     <div | ||||
|       v-for="(message, index) in messages" | ||||
|       :key="generateMessageKey(index, message)" | ||||
|       class="card" | ||||
|       :class="message.type || 'warning'" | ||||
|     > | ||||
|       <span class="pinstripe"></span> | ||||
|       <div> | ||||
|         <h2>{{ message.title || defaultTitles[message.type] }}</h2> | ||||
|         <span>{{ message.message }}</span> | ||||
|       <div class="content"> | ||||
|         <h2 class="title"> | ||||
|           {{ message.title || titleFromType(message.type) }} | ||||
|         </h2> | ||||
|         <span v-if="message.message" class="message">{{ | ||||
|           message.message | ||||
|         }}</span> | ||||
|       </div> | ||||
|  | ||||
|       <button class="dismiss" @click="clicked(message)">X</button> | ||||
|       <button class="dismiss" @click="dismiss(Number(index))">X</button> | ||||
|     </div> | ||||
|   </transition-group> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, defineEmits } from "vue"; | ||||
|   import type { | ||||
|     ErrorMessageTypes, | ||||
|     IErrorMessage | ||||
|   } from "../../interfaces/IErrorMessage"; | ||||
|  | ||||
| export default { | ||||
|   props: { | ||||
|     messages: { | ||||
|       required: true, | ||||
|       type: Array | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       defaultTitles: { | ||||
|         error: 'Unexpected error', | ||||
|         warning: 'Something went wrong', | ||||
|         undefined: 'Something went wrong' | ||||
|       }, | ||||
|       localMessages: [...this.messages] | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     reversedMessages() { | ||||
|       return [...this.messages].reverse() | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     clicked(e) { | ||||
|       const removedMessage = [...this.messages].filter(mes => mes !== e) | ||||
|       this.$emit('update:messages', removedMessage) | ||||
|     } | ||||
|   }, | ||||
|   // watch: { | ||||
|   //   messages(propState, oldState) { | ||||
|   //     const newMessage = propState.filter(msg => !this.localMessages.includes(msg)) | ||||
|   //     console.log('newMessage', newMessage) | ||||
|   //     this.localMessages = this.localMessages.concat(newMessage) | ||||
|   //   } | ||||
|   // } | ||||
| } | ||||
|   interface Props { | ||||
|     messages: IErrorMessage[]; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "update:messages", messages: IErrorMessage[]); | ||||
|   } | ||||
|  | ||||
|   const props = defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   const defaultTitles = { | ||||
|     error: "Unexpected error", | ||||
|     warning: "Something went wrong", | ||||
|     success: "Success!", | ||||
|     undefined: "Something went wrong" | ||||
|   }; | ||||
|  | ||||
|   function titleFromType(type: ErrorMessageTypes) { | ||||
|     return defaultTitles[type]; | ||||
|   } | ||||
|  | ||||
|   function dismiss(index: number) { | ||||
|     const _messages = [...props.messages]; | ||||
|     _messages.splice(index, 1); | ||||
|     emit("update:messages", _messages); | ||||
|   } | ||||
|  | ||||
|   function generateMessageKey( | ||||
|     index: string | number | symbol, | ||||
|     errorMessage: IErrorMessage | ||||
|   ) { | ||||
|     return `${String(index)}-${errorMessage.title}-${errorMessage.type}`; | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| .fade-enter-active { | ||||
|   transition: opacity .4s; | ||||
| } | ||||
| .fade-leave-active { | ||||
|   transition: opacity .1s; | ||||
| } | ||||
| .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { | ||||
|   opacity: 0; | ||||
| } | ||||
|   @import "src/scss/variables"; | ||||
|   @import "src/scss/media-queries"; | ||||
|  | ||||
| .message { | ||||
|   width: 100%; | ||||
|   max-width: 35rem; | ||||
|   height: 75px; | ||||
|   .fade-active { | ||||
|     transition: opacity 0.4s; | ||||
|   } | ||||
|   .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ { | ||||
|     opacity: 0; | ||||
|   } | ||||
|  | ||||
|   display: flex; | ||||
|   margin-top: 1rem; | ||||
|   margin-bottom: 1rem; | ||||
|   color: $text-color-70; | ||||
|  | ||||
|   > div { | ||||
|     margin: 6px 24px; | ||||
|   .card { | ||||
|     width: 100%; | ||||
|     max-width: 35rem; | ||||
|  | ||||
|   } | ||||
|  | ||||
|   h2 { | ||||
|     font-weight: 300; | ||||
|     letter-spacing: 0.25px; | ||||
|     margin: 0; | ||||
|     font-size: 1.3rem; | ||||
|     color: $text-color; | ||||
|     transition: color .5s ease; | ||||
|   } | ||||
|   span { | ||||
|     font-weight: 300; | ||||
|     display: flex; | ||||
|     margin-top: 0.8rem; | ||||
|     color: $text-color-70; | ||||
|     transition: color .5s ease; | ||||
|   } | ||||
|  | ||||
|   .pinstripe { | ||||
|     height: 100%; | ||||
|     width: 0.5rem; | ||||
|     // background-color: $color-error-highlight; | ||||
|   } | ||||
|     .content { | ||||
|       margin: 0.4rem 1.2rem; | ||||
|       width: 100%; | ||||
|  | ||||
|   .dismiss { | ||||
|     position: relative; | ||||
|     -webkit-appearance: none; | ||||
|     -moz-appearance: none; | ||||
|     background-color: transparent; | ||||
|     border: unset; | ||||
|     font-size: 18px; | ||||
|     cursor: pointer; | ||||
|       .title { | ||||
|         font-weight: 300; | ||||
|         letter-spacing: 0.25px; | ||||
|         margin: 0; | ||||
|         font-size: 1.3rem; | ||||
|         color: $text-color; | ||||
|         transition: color 0.5s ease; | ||||
|       } | ||||
|  | ||||
|     top: 0; | ||||
|     float: right; | ||||
|     height: 1.5rem; | ||||
|     width: 1.5rem; | ||||
|     padding: 0; | ||||
|     margin-top: 0.5rem; | ||||
|     margin-right: 0.5rem; | ||||
|     color: $text-color-70; | ||||
|     transition: color .5s ease; | ||||
|       .message { | ||||
|         font-weight: 400; | ||||
|         font-size: 1.2rem; | ||||
|         color: $text-color-70; | ||||
|         transition: color 0.5s ease; | ||||
|         margin-bottom: 0.2rem; | ||||
|       } | ||||
|  | ||||
|     &:hover { | ||||
|       color: $text-color; | ||||
|       @include mobile-only { | ||||
|         margin: 6px 6px; | ||||
|         line-height: 1.3rem; | ||||
|  | ||||
|         h2 { | ||||
|           font-size: 1.1rem; | ||||
|         } | ||||
|         span { | ||||
|           font-size: 0.9rem; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.success { | ||||
|     background-color: $color-success; | ||||
|  | ||||
|     .pinstripe { | ||||
|       background-color: $color-success-highlight; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   &.error { | ||||
|     background-color: $color-error; | ||||
|  | ||||
|     .pinstripe { | ||||
|       width: 0.5rem; | ||||
|       background-color: $color-error-highlight; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.warning { | ||||
|     background-color: $color-warning; | ||||
|     .dismiss { | ||||
|       position: relative; | ||||
|       -webkit-appearance: none; | ||||
|       -moz-appearance: none; | ||||
|       background-color: transparent; | ||||
|       border: unset; | ||||
|       font-size: 18px; | ||||
|       cursor: pointer; | ||||
|  | ||||
|     .pinstripe { | ||||
|       background-color: $color-warning-highlight; | ||||
|       top: 0; | ||||
|       float: right; | ||||
|       height: 1.5rem; | ||||
|       width: 1.5rem; | ||||
|       padding: 0; | ||||
|       margin-top: 0.5rem; | ||||
|       margin-right: 0.5rem; | ||||
|       color: $text-color-70; | ||||
|       transition: color 0.5s ease; | ||||
|  | ||||
|       &:hover { | ||||
|         color: $text-color; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.success { | ||||
|       background-color: $color-success; | ||||
|  | ||||
|       .pinstripe { | ||||
|         background-color: $color-success-highlight; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.error { | ||||
|       background-color: $color-error; | ||||
|  | ||||
|       .pinstripe { | ||||
|         background-color: $color-error-highlight; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.warning { | ||||
|       background-color: $color-warning; | ||||
|  | ||||
|       .pinstripe { | ||||
|         background-color: $color-warning-highlight; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| </style> | ||||
| @@ -1,9 +0,0 @@ | ||||
| <template> | ||||
|   <div v-html="require(`@/assets/icons/${ icon }.svg`)"></div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
|   export default { | ||||
|     props: ['icon'] | ||||
|   } | ||||
| </script> | ||||
							
								
								
									
										76
									
								
								src/components/ui/ToggleButton.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,76 @@ | ||||
| <template> | ||||
|   <div class="toggle-container"> | ||||
|     <button | ||||
|       v-for="option in options" | ||||
|       :key="option" | ||||
|       class="toggle-button" | ||||
|       :class="selected === option ? 'selected' : null" | ||||
|       @click="() => toggleTo(option)" | ||||
|     > | ||||
|       {{ option }} | ||||
|     </button> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   import { defineProps, defineEmits } from "vue"; | ||||
|  | ||||
|   interface Props { | ||||
|     options: string[]; | ||||
|     selected?: string; | ||||
|   } | ||||
|  | ||||
|   interface Emit { | ||||
|     (e: "update:selected", selected: string); | ||||
|     (e: "change"); | ||||
|   } | ||||
|  | ||||
|   defineProps<Props>(); | ||||
|   const emit = defineEmits<Emit>(); | ||||
|  | ||||
|   function toggleTo(option: string) { | ||||
|     emit("update:selected", option); | ||||
|     emit("change"); | ||||
|   } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   @import "src/scss/variables"; | ||||
|  | ||||
|   $background: $background-ui; | ||||
|   $background-selected: $background-color-secondary; | ||||
|  | ||||
|   .toggle-container { | ||||
|     width: 100%; | ||||
|     display: flex; | ||||
|     overflow-x: scroll; | ||||
|     flex-direction: row; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     background-color: $background; | ||||
|     border: 2px solid $background; | ||||
|     border-radius: 8px; | ||||
|     border-left: 4px solid $background; | ||||
|     border-right: 4px solid $background; | ||||
|  | ||||
|     .toggle-button { | ||||
|       font-size: 1rem; | ||||
|       line-height: 1rem; | ||||
|       font-weight: normal; | ||||
|       padding: 0.5rem; | ||||
|       border: 0; | ||||
|       color: $text-color; | ||||
|       background-color: $background; | ||||
|       text-transform: capitalize; | ||||
|       cursor: pointer; | ||||
|       display: block; | ||||
|       flex: 1 0 auto; | ||||
|  | ||||
|       &.selected { | ||||
|         color: $text-color; | ||||
|         background-color: $background-selected; | ||||
|         border-radius: 8px; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </style> | ||||
| @@ -1,51 +0,0 @@ | ||||
| <template> | ||||
|  | ||||
|   <div class="darkToggle"> | ||||
|     <span @click="toggleDarkmode()">{{ darkmodeToggleIcon }}</span> | ||||
|   </div> | ||||
|  | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| export default { | ||||
|  | ||||
|   data() { | ||||
|     return { | ||||
|       darkmode: window.getComputedStyle(document.body).colorScheme.includes('dark') | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     toggleDarkmode() { | ||||
|       this.darkmode = !this.darkmode; | ||||
|       document.body.className = this.darkmode ? 'dark' : 'light' | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     darkmodeToggleIcon() { | ||||
|       return this.darkmode ? '🌝' : '🌚' | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| </script> | ||||
|  | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .darkToggle { | ||||
|   height: 25px; | ||||
|   width: 25px; | ||||
|   cursor: pointer; | ||||
|   // background-color: red; | ||||
|   position: fixed; | ||||
|   margin-bottom: 10px; | ||||
|   margin-right: 2px; | ||||
|   bottom: 0; | ||||
|   right: 0; | ||||
|   z-index: 1; | ||||
|  | ||||
|   -webkit-user-select: none; | ||||
|   -moz-user-select: none; | ||||
|   -ms-user-select: none; | ||||
|   user-select: none; | ||||
| } | ||||
| </style> | ||||
| @@ -1,122 +0,0 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <a @click="$emit('click')"><li> | ||||
|       <figure :class="activeClassIfActive"> | ||||
|         <svg><use :xlink:href="iconRefNameIfActive"/></svg> | ||||
|       </figure> | ||||
|  | ||||
|       <span :class="activeClassIfActive">{{ contentTextToDisplay }}</span> | ||||
|  | ||||
|       <span v-if="supplementaryText" class="supplementary-text"> | ||||
|         {{ supplementaryText }} | ||||
|       </span> | ||||
|     </li></a> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
|   // TODO if a image is hovered and we can't set the hover color we want to | ||||
|   // go into it and change the fill | ||||
| export default { | ||||
|   props: { | ||||
|     iconRef: { | ||||
|       type: String, | ||||
|       required: true | ||||
|     }, | ||||
|     iconRefActive: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     }, | ||||
|     active: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|     textActive: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     }, | ||||
|     supplementaryText: { | ||||
|       type: String, | ||||
|       required: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     iconRefNameIfActive() { | ||||
|       const { iconRefActive, iconRef, active } = this | ||||
|  | ||||
|       if ((iconRefActive && iconRef) & active) { | ||||
|         return iconRefActive | ||||
|       } | ||||
|       return iconRef | ||||
|     }, | ||||
|     contentTextToDisplay() { | ||||
|       const { textActive, active, $slots } = this | ||||
|  | ||||
|       if (textActive && active) | ||||
|         return textActive | ||||
|  | ||||
|       if ($slots.default && $slots.default.length > 0) | ||||
|         return $slots.default[0].text | ||||
|  | ||||
|       return '' | ||||
|     }, | ||||
|     activeClassIfActive() { | ||||
|       return this.active ? 'active' : '' | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| @import "./src/scss/variables"; | ||||
| @import "./src/scss/media-queries"; | ||||
|  | ||||
| li { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   text-decoration: none; | ||||
|   text-transform: uppercase; | ||||
|   color: $text-color-50; | ||||
|   transition: color 0.5s ease; | ||||
|   font-size: 11px; | ||||
|   padding: 10px 0; | ||||
|   border-bottom: 1px solid $text-color-5; | ||||
|  | ||||
|   &:hover { | ||||
|     color: $text-color-70; | ||||
|     cursor: pointer; | ||||
|   } | ||||
|   .active { | ||||
|     color: $text-color; | ||||
|   } | ||||
|   .pending { | ||||
|     color: #f8bd2d; | ||||
|   } | ||||
|  | ||||
|   .supplementary-text { | ||||
|     flex-grow: 1; | ||||
|     text-align: right; | ||||
|   } | ||||
|  | ||||
|   figure, figure > svg { | ||||
|     width: 18px; | ||||
|     height: 18px; | ||||
|     margin: 0 7px 0 0; | ||||
|     fill: $text-color-50; | ||||
|     transition: fill 0.5s ease, transform 0.5s ease; | ||||
|     &.waiting { | ||||
|       transform: scale(0.8, 0.8); | ||||
|     } | ||||
|     &.pending { | ||||
|       fill: #f8bd2d; | ||||
|     } | ||||
|     &:hover &-icon { | ||||
|       fill: $text-color-70; | ||||
|       cursor: pointer; | ||||
|     } | ||||
|     &.active > svg { | ||||
|       fill: $green; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
| @@ -1,5 +0,0 @@ | ||||
| { | ||||
|     "SEASONED_URL": "http://localhost:31459/api/", | ||||
|     "ELASTIC_URL": "http://localhost:9200", | ||||
|     "ELASTIC_INDEX": "shows,movies" | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/icons/BubbleArrowLeft.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | ||||
| <template> | ||||
|   <svg | ||||
|     version="1.1" | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     width="100%" | ||||
|     height="100%" | ||||
|     viewBox="0 0 24 24" | ||||
|     fill="none" | ||||
|     stroke="currentColor" | ||||
|     stroke-width="2" | ||||
|     stroke-linecap="round" | ||||
|     stroke-linejoin="round" | ||||
|     class="feather feather-arrow-left-circle" | ||||
|   > | ||||
|     <circle cx="12" cy="12" r="10"></circle> | ||||
|     <polyline points="12 8 8 12 12 16"></polyline> | ||||
|     <line x1="16" y1="12" x2="8" y2="12"></line> | ||||
|   </svg> | ||||
| </template> | ||||
							
								
								
									
										19
									
								
								src/icons/BubbleArrowRight.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,19 @@ | ||||
| <template> | ||||
|   <svg | ||||
|     version="1.1" | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     width="100%" | ||||
|     height="100%" | ||||
|     viewBox="0 0 24 24" | ||||
|     fill="none" | ||||
|     stroke="currentColor" | ||||
|     stroke-width="2" | ||||
|     stroke-linecap="round" | ||||
|     stroke-linejoin="round" | ||||
|     class="feather feather-arrow-right-circle" | ||||
|   > | ||||
|     <circle cx="12" cy="12" r="10"></circle> | ||||
|     <polyline points="12 16 16 12 12 8"></polyline> | ||||
|     <line x1="8" y1="12" x2="16" y2="12"></line> | ||||
|   </svg> | ||||
| </template> | ||||
							
								
								
									
										15
									
								
								src/icons/IconActivity.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,15 @@ | ||||
| <template> | ||||
|   <svg | ||||
|     version="1.1" | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     width="24" | ||||
|     height="24" | ||||
|     viewBox="0 0 24 24" | ||||
|     stroke="currentColor" | ||||
|     stroke-width="2" | ||||
|     stroke-linecap="round" | ||||
|     stroke-linejoin="round" | ||||
|   > | ||||
|     <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> | ||||
|   </svg> | ||||
| </template> | ||||
							
								
								
									
										7
									
								
								src/icons/IconArrowDown.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| <template> | ||||
|   <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> | ||||
|     <path | ||||
|       d="M28.725 8.058l-12.725 12.721-12.725-12.721-1.887 1.887 13.667 13.667c0.258 0.258 0.6 0.392 0.942 0.392s0.683-0.129 0.942-0.392l13.667-13.667-1.879-1.887z" | ||||
|     /> | ||||
|   </svg> | ||||
| </template> | ||||
							
								
								
									
										13
									
								
								src/icons/IconBinoculars.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | ||||
| <template> | ||||
|   <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> | ||||
|     <path | ||||
|       d="M31.313 18.896v0l-5.071-10.846c-0.004-0.008-0.008-0.017-0.012-0.029-0.542-1.154-1.529-2.021-2.696-2.429l-1.129-1.921c-0.004-0.004-0.008-0.013-0.012-0.017-0.358-0.608-1.021-0.987-1.725-0.987-1.104 0-2 0.896-2 2v2.071c-0.825 0.842-1.333 1.996-1.333 3.263v1.025c-0.392-0.229-0.85-0.358-1.333-0.358s-0.942 0.129-1.333 0.358v-1.025c0-1.267-0.508-2.421-1.333-3.263v-2.071c0-1.104-0.896-2-2-2-0.704 0-1.367 0.379-1.725 0.987-0.004 0.004-0.008 0.013-0.012 0.017l-1.129 1.921c-1.171 0.408-2.158 1.275-2.696 2.429-0.004 0.008-0.008 0.017-0.013 0.025l-5.071 10.85c-0.442 0.946-0.688 1.996-0.688 3.104 0 4.042 3.292 7.333 7.333 7.333 3.942 0 7.167-3.125 7.325-7.029 0.396 0.229 0.85 0.363 1.342 0.363 0.488 0 0.946-0.133 1.342-0.363 0.158 3.904 3.383 7.029 7.325 7.029 4.042 0 7.333-3.292 7.333-7.333 0-1.108-0.246-2.158-0.688-3.104zM26.5 14.9c-0.587-0.15-1.2-0.233-1.833-0.233-1.771 0-3.396 0.629-4.667 1.679v-6.346c0-1.104 0.896-2 2-2 0.767 0 1.471 0.446 1.804 1.133 0.004 0.008 0.008 0.012 0.008 0.021l2.688 5.746zM20.667 4c0.233 0 0.446 0.117 0.567 0.317 0.004 0.004 0.004 0.008 0.008 0.013l0.592 1.008c-0.654 0.025-1.275 0.183-1.833 0.446v-1.117c0-0.367 0.3-0.667 0.667-0.667zM16 12c0.733 0 1.333 0.6 1.333 1.333v4.358c-0.392-0.229-0.85-0.358-1.333-0.358s-0.942 0.129-1.333 0.358v-4.358c0-0.733 0.6-1.333 1.333-1.333zM10.767 4.317c0.121-0.2 0.333-0.317 0.567-0.317 0.367 0 0.667 0.3 0.667 0.667v1.117c-0.558-0.267-1.179-0.425-1.833-0.446l0.592-1.008c0.004-0.004 0.004-0.008 0.008-0.013zM8.188 9.154c0.004-0.008 0.004-0.012 0.008-0.021 0.333-0.688 1.037-1.133 1.804-1.133 1.104 0 2 0.896 2 2v6.346c-1.271-1.050-2.896-1.679-4.667-1.679-0.633 0-1.246 0.079-1.833 0.233l2.688-5.746zM7.333 26.667c-2.575 0-4.667-2.092-4.667-4.667s2.092-4.667 4.667-4.667 4.667 2.092 4.667 4.667-2.092 4.667-4.667 4.667zM16 21.333c-0.733 0-1.333-0.6-1.333-1.333s0.6-1.333 1.333-1.333c0.733 0 1.333 0.6 1.333 1.333s-0.6 1.333-1.333 1.333zM24.667 26.667c-2.575 0-4.667-2.092-4.667-4.667s2.092-4.667 4.667-4.667 4.667 2.092 4.667 4.667-2.092 4.667-4.667 4.667z" | ||||
|     /> | ||||
|     <path | ||||
|       d="M5.333 22v-0.667h-1.333v0.667c0 1.837 1.496 3.333 3.333 3.333h0.667v-1.333h-0.667c-1.104 0-2-0.896-2-2z" | ||||
|     /> | ||||
|     <path | ||||
|       d="M22.667 22v-0.667h-1.333v0.667c0 1.837 1.496 3.333 3.333 3.333h0.667v-1.333h-0.667c-1.104 0-2-0.896-2-2z" | ||||
|     /> | ||||
|   </svg> | ||||
| </template> | ||||
							
								
								
									
										16
									
								
								src/icons/IconClose.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,16 @@ | ||||
| <template> | ||||
|   <svg | ||||
|     id="icon-cross" | ||||
|     viewBox="0 0 32 32" | ||||
|     version="1.1" | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     style="transition-duration: 0s" | ||||
|     @click="$emit('click')" | ||||
|     @keydown="event => $emit('keydown', event)" | ||||
|   > | ||||
|     <path | ||||
|       fill="inherit" | ||||
|       d="M27.942 5.942l-1.883-1.883-10.058 10.054-10.058-10.054-1.883 1.883 10.054 10.058-10.054 10.058 1.883 1.883 10.058-10.054 10.058 10.054 1.883-1.883-10.054-10.058z" | ||||
|     ></path> | ||||
|   </svg> | ||||
| </template> | ||||
							
								
								
									
										13
									
								
								src/icons/IconEdit.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | ||||
| <template> | ||||
|   <svg | ||||
|     version="1.1" | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     viewBox="0 0 32 32" | ||||
|     @click="$emit('click')" | ||||
|     @keydown.enter="$emit('click')" | ||||
|   > | ||||
|     <path | ||||
|       d="M30.229 1.771c-1.142-1.142-2.658-1.771-4.275-1.771s-3.133 0.629-4.275 1.771l-18.621 18.621c-0.158 0.158-0.275 0.358-0.337 0.575l-2.667 9.333c-0.133 0.467-0.004 0.967 0.338 1.308 0.254 0.254 0.596 0.392 0.942 0.392 0.121 0 0.246-0.017 0.367-0.050l9.333-2.667c0.217-0.063 0.417-0.179 0.575-0.337l18.621-18.621c2.358-2.362 2.358-6.196 0-8.554zM6.079 21.137l14.392-14.392 4.779 4.779-14.387 14.396-4.783-4.783zM21.413 5.804l1.058-1.058 4.779 4.779-1.058 1.058-4.779-4.779zM5.167 22.108l4.725 4.725-6.617 1.892 1.892-6.617zM28.346 8.438l-0.15 0.15-4.783-4.783 0.15-0.15c0.642-0.637 1.488-0.988 2.392-0.988s1.75 0.35 2.392 0.992c1.317 1.317 1.317 3.458 0 4.779z" | ||||
|     /> | ||||
|   </svg> | ||||
| </template> | ||||
							
								
								
									
										7
									
								
								src/icons/IconEmail.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| <template> | ||||
|   <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> | ||||
|     <path | ||||
|       d="M30.742 9.771c-0.804-1.904-1.958-3.617-3.429-5.083-1.471-1.471-3.179-2.621-5.083-3.429-1.975-0.833-4.071-1.258-6.229-1.258s-4.254 0.425-6.229 1.258c-1.904 0.804-3.617 1.958-5.083 3.429-1.471 1.471-2.621 3.179-3.429 5.083-0.833 1.975-1.258 4.071-1.258 6.229s0.425 4.254 1.258 6.229c0.804 1.904 1.958 3.617 3.429 5.083 1.471 1.471 3.179 2.621 5.083 3.429 1.975 0.833 4.071 1.258 6.229 1.258h6.667v-2.667h-6.667c-7.35 0-13.333-5.983-13.333-13.333s5.983-13.333 13.333-13.333c7.35 0 13.333 5.983 13.333 13.333v0.667c0 1.837-1.496 3.333-3.333 3.333s-3.333-1.496-3.333-3.333v-7.333h-2.667v1.338c-1.117-0.838-2.5-1.338-4-1.338-3.675 0-6.667 2.992-6.667 6.667s2.992 6.667 6.667 6.667c2.079 0 3.938-0.958 5.162-2.454 1.092 1.488 2.854 2.454 4.837 2.454 3.308 0 6-2.692 6-6v-0.667c0-2.158-0.425-4.254-1.258-6.229zM16 20c-2.204 0-4-1.796-4-4s1.796-4 4-4 4 1.796 4 4-1.796 4-4 4z" | ||||
|     /> | ||||
|   </svg> | ||||
| </template> | ||||
							
								
								
									
										22
									
								
								src/icons/IconExpand.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,22 @@ | ||||
| <template> | ||||
|   <svg | ||||
|     id="icon-full-screen-enter" | ||||
|     viewBox="0 0 32 32" | ||||
|     width="100%" | ||||
|     height="100%" | ||||
|     style="transition-duration: 0s" | ||||
|   > | ||||
|     <path | ||||
|       style="transition-duration: 0s" | ||||
|       d="M29.333 2.667h-26.667c-1.471 0-2.667 1.196-2.667 2.667v21.333c0 1.471 1.196 2.667 2.667 2.667h26.667c1.471 0 2.667-1.196 2.667-2.667v-21.333c0-1.471-1.196-2.667-2.667-2.667zM29.333 26.667h-26.667v-21.333h26.667v21.333c0.004 0 0 0 0 0z" | ||||
|     ></path> | ||||
|     <path | ||||
|       style="transition-duration: 0s" | ||||
|       d="M11.333 17.058l-4.667 4.667v-1.725h-1.333v3.333c0 0.367 0.3 0.667 0.667 0.667h3.333v-1.333h-1.725l4.667-4.667-0.942-0.942z" | ||||
|     ></path> | ||||
|     <path | ||||
|       style="transition-duration: 0s" | ||||
|       d="M26 8h-3.333v1.333h1.725l-4.667 4.667 0.942 0.942 4.667-4.667v1.725h1.333v-3.333c0-0.367-0.3-0.667-0.667-0.667z" | ||||
|     ></path> | ||||
|   </svg> | ||||
| </template> | ||||