Compare commits
	
		
			2 Commits
		
	
	
		
			master
			...
			feat/botto
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 2665a27803 | |||
| 74b96225c6 | 
							
								
								
									
										79
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						@@ -7,79 +7,13 @@ 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
 | 
			
		||||
- name: frontend_install
 | 
			
		||||
  image: node:13.6.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
 | 
			
		||||
- name: deploy
 | 
			
		||||
  image: appleboy/drone-ssh
 | 
			
		||||
  pull: true
 | 
			
		||||
  secrets:
 | 
			
		||||
@@ -92,7 +26,7 @@ steps:
 | 
			
		||||
      - drone-test
 | 
			
		||||
    status: success
 | 
			
		||||
  settings:
 | 
			
		||||
      host: 10.0.0.54
 | 
			
		||||
    host: 10.0.0.114
 | 
			
		||||
    username: root
 | 
			
		||||
    key:
 | 
			
		||||
      from_secret: ssh_key
 | 
			
		||||
@@ -101,7 +35,10 @@ steps:
 | 
			
		||||
      - /home/kevin/deploy/seasoned.sh
 | 
			
		||||
 | 
			
		||||
trigger:
 | 
			
		||||
  branch:
 | 
			
		||||
    - master
 | 
			
		||||
  event:
 | 
			
		||||
    include:
 | 
			
		||||
      - pull_request
 | 
			
		||||
      - push
 | 
			
		||||
      # - pull_request
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +0,0 @@
 | 
			
		||||
SEASONED_API=
 | 
			
		||||
ELASTIC=
 | 
			
		||||
ELASTIC_INDEX=shows,movies
 | 
			
		||||
SEASONED_DOMAIN=
 | 
			
		||||
							
								
								
									
										31
									
								
								.eslintrc
									
									
									
									
									
								
							
							
						
						@@ -1,31 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
  "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,6 +1,5 @@
 | 
			
		||||
# config file - copy config.json.example
 | 
			
		||||
src/config.json
 | 
			
		||||
.env
 | 
			
		||||
 | 
			
		||||
# Build directory
 | 
			
		||||
dist/
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										8
									
								
								.htaccess
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,8 @@
 | 
			
		||||
<IfModule mod_rewrite.c>
 | 
			
		||||
  RewriteEngine On
 | 
			
		||||
  RewriteBase /
 | 
			
		||||
  RewriteRule ^index\.html$ - [L]
 | 
			
		||||
  RewriteCond %{REQUEST_FILENAME} !-f
 | 
			
		||||
  RewriteCond %{REQUEST_FILENAME} !-d
 | 
			
		||||
  RewriteRule . /index.html [L]
 | 
			
		||||
</IfModule>
 | 
			
		||||
@@ -5,6 +5,6 @@
 | 
			
		||||
  "singleQuote": false,
 | 
			
		||||
  "bracketSpacing": true,
 | 
			
		||||
  "arrowParens": "avoid",
 | 
			
		||||
  "vueIndentScriptAndStyle": true,
 | 
			
		||||
  "vueIndentScriptAndStyle": false,
 | 
			
		||||
  "trailingComma": "none"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										11
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						@@ -1,11 +0,0 @@
 | 
			
		||||
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,72 +1,45 @@
 | 
			
		||||
# Seasoned Request
 | 
			
		||||
# The Movie Database App
 | 
			
		||||
 | 
			
		||||
Seasoned request is frontend vue application for searching, requesting and viewing account watch activity.
 | 
			
		||||
A Vue.js project.
 | 
			
		||||
 | 
			
		||||

 | 
			
		||||
 | 
			
		||||
## 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.
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# make copy of example environment file
 | 
			
		||||
cp .env.example .env
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "SEASONED_URL": "http://localhost:31459/api",
 | 
			
		||||
  "ELASTIC_URL": "http://localhost:9200"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
*Set ELASTIC_URL to undefined or false to disable*
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
# .env sane default values
 | 
			
		||||
SEASONED_API=
 | 
			
		||||
ELASTIC=
 | 
			
		||||
ELASTIC_INDEX=shows,movies
 | 
			
		||||
SEASONED_DOMAIN=
 | 
			
		||||
```
 | 
			
		||||
## Build Setup
 | 
			
		||||
 | 
			
		||||
- 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
 | 
			
		||||
``` bash
 | 
			
		||||
# install dependencies
 | 
			
		||||
yarn
 | 
			
		||||
npm install
 | 
			
		||||
 | 
			
		||||
# build vue project using webpack
 | 
			
		||||
yarn build
 | 
			
		||||
# serve with hot reload at localhost:8080
 | 
			
		||||
npm run dev
 | 
			
		||||
 | 
			
		||||
# 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
 | 
			
		||||
# build for production with minification
 | 
			
		||||
npm run build
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## 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
 | 
			
		||||
```
 | 
			
		||||
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)
 | 
			
		||||
 | 
			
		||||
## 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)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +0,0 @@
 | 
			
		||||
#!/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 "$@"
 | 
			
		||||
| 
		 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  | 
							
								
								
									
										128
									
								
								index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										30
									
								
								nginx.conf
									
									
									
									
									
								
							
							
						
						@@ -1,30 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										82
									
								
								package.json
									
									
									
									
									
								
							
							
						
						@@ -1,61 +1,45 @@
 | 
			
		||||
{
 | 
			
		||||
  "name": "seasoned-request",
 | 
			
		||||
  "description": "seasoned request app",
 | 
			
		||||
  "version": "1.22.17",
 | 
			
		||||
  "version": "1.0.0",
 | 
			
		||||
  "author": "Kevin Midboe",
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "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"
 | 
			
		||||
    "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"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "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"
 | 
			
		||||
    "axios": "^0.18.1",
 | 
			
		||||
    "babel-plugin-transform-object-rest-spread": "^6.26.0",
 | 
			
		||||
    "chart.js": "^2.9.2",
 | 
			
		||||
    "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"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@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"
 | 
			
		||||
    "@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": "^3.4.2",
 | 
			
		||||
    "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"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 641 KiB  | 
| 
		 Before Width: | Height: | Size: 331 KiB  | 
| 
		 Before Width: | Height: | Size: 6.3 KiB  | 
| 
		 Before Width: | Height: | Size: 6.3 KiB  | 
							
								
								
									
										23
									
								
								server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,23 @@
 | 
			
		||||
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);
 | 
			
		||||
							
								
								
									
										205
									
								
								src/App.vue
									
									
									
									
									
								
							
							
						
						@@ -1,70 +1,163 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div id="app">
 | 
			
		||||
    <!-- Header and hamburger navigation -->
 | 
			
		||||
    <NavigationHeader class="header" />
 | 
			
		||||
    <navigation></navigation>
 | 
			
		||||
    <search-input v-model="query"></search-input>
 | 
			
		||||
 | 
			
		||||
    <div class="navigation-icons-gutter desktop-only">
 | 
			
		||||
      <NavigationIcons />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <!-- Display the component assigned to the given route (default: home) -->
 | 
			
		||||
    <router-view :key="router.currentRoute.value.path" class="content" />
 | 
			
		||||
 | 
			
		||||
    <!-- Popup that will show above existing rendered content -->
 | 
			
		||||
    <popup />
 | 
			
		||||
    <!-- Movie popup that will show above existing rendered content -->
 | 
			
		||||
    <movie-popup
 | 
			
		||||
      v-if="moviePopupIsVisible"
 | 
			
		||||
      :id="popupID"
 | 
			
		||||
      :type="popupType"
 | 
			
		||||
    ></movie-popup>
 | 
			
		||||
 | 
			
		||||
    <darkmode-toggle />
 | 
			
		||||
 | 
			
		||||
    <!-- Display the component assigned to the given route (default: home) -->
 | 
			
		||||
    <router-view class="content" :key="$route.fullPath"></router-view>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<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";
 | 
			
		||||
<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";
 | 
			
		||||
 | 
			
		||||
  const router = useRouter();
 | 
			
		||||
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
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss">
 | 
			
		||||
  @import "src/scss/main";
 | 
			
		||||
  @import "src/scss/media-queries";
 | 
			
		||||
 | 
			
		||||
  #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;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
<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";
 | 
			
		||||
 | 
			
		||||
* {
 | 
			
		||||
  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 0.5s ease, color 0.5s ease;
 | 
			
		||||
  &.hidden {
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
h1,
 | 
			
		||||
h2,
 | 
			
		||||
h3 {
 | 
			
		||||
  transition: color 0.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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.no-scroll {
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.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>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										490
									
								
								src/api.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,490 @@
 | 
			
		||||
import axios from 'axios'
 | 
			
		||||
import storage from '@/storage'
 | 
			
		||||
import config from '@/config.json'
 | 
			
		||||
import path from 'path'
 | 
			
		||||
import store from '@/store'
 | 
			
		||||
 | 
			
		||||
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?
 | 
			
		||||
 | 
			
		||||
const checkStatusAndReturnJson = (response) => {
 | 
			
		||||
  if (!response.ok) {
 | 
			
		||||
    throw resp
 | 
			
		||||
  }
 | 
			
		||||
  return response.json()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// - - - 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, checkExistance=false, credits=false, release_dates=false) => {
 | 
			
		||||
  const url = new URL('v2/movie', SEASONED_URL)
 | 
			
		||||
  url.pathname = path.join(url.pathname, id.toString())
 | 
			
		||||
  if (checkExistance) {
 | 
			
		||||
    url.searchParams.append('check_existance', true)
 | 
			
		||||
  }
 | 
			
		||||
  if (credits) {
 | 
			
		||||
    url.searchParams.append('credits', true)
 | 
			
		||||
  }
 | 
			
		||||
  if(release_dates) {
 | 
			
		||||
    url.searchParams.append('release_dates', 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, checkExistance=false, credits=false) => {
 | 
			
		||||
  const url = new URL('v2/show', SEASONED_URL)
 | 
			
		||||
  url.pathname = path.join(url.pathname, id.toString())
 | 
			
		||||
  if (checkExistance) {
 | 
			
		||||
    url.searchParams.append('check_existance', true)
 | 
			
		||||
  }
 | 
			
		||||
  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 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('v2/person', 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 person: ${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, adult=false, mediaType=null) => {
 | 
			
		||||
  const url = new URL('v2/search', SEASONED_URL)
 | 
			
		||||
  if (mediaType != null && ['movie', 'show', 'person'].includes(mediaType)) {
 | 
			
		||||
    url.pathname += `/${mediaType}`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  url.searchParams.append('query', query)
 | 
			
		||||
  url.searchParams.append('page', page)
 | 
			
		||||
  url.searchParams.append('adult', adult)
 | 
			
		||||
 | 
			
		||||
  const headers = { authorization: localStorage.getItem('token') }
 | 
			
		||||
 | 
			
		||||
  return fetch(url.href, { headers })
 | 
			
		||||
    .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 = JSON.stringify({
 | 
			
		||||
    magnet: magnet,
 | 
			
		||||
    name: name,
 | 
			
		||||
    tmdb_id: tmdb_id
 | 
			
		||||
  })
 | 
			
		||||
  const headers = {
 | 
			
		||||
    'Content-Type': 'application/json',
 | 
			
		||||
    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))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const watchLink = (title, year, authorization_token=undefined) => {
 | 
			
		||||
  const url = new URL('v1/plex/watch-link', SEASONED_URL)
 | 
			
		||||
  url.searchParams.append('title', title)
 | 
			
		||||
  url.searchParams.append('year', year)
 | 
			
		||||
 | 
			
		||||
  const headers = {
 | 
			
		||||
    'Authorization': authorization_token,
 | 
			
		||||
    'Content-Type': 'application/json'
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return fetch(url.href, { headers })
 | 
			
		||||
    .then(resp => resp.json())
 | 
			
		||||
    .then(response => response.link)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// - - - Seasoned user endpoints - - -
 | 
			
		||||
 | 
			
		||||
const register = (username, password) => {
 | 
			
		||||
  const url = new URL('v1/user', SEASONED_URL)
 | 
			
		||||
  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 => {
 | 
			
		||||
      console.error('Unexpected error occured before receiving response. Error:', error)
 | 
			
		||||
      // TODO log to sentry the issue here
 | 
			
		||||
      throw error
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const login = (username, password, throwError=false) => {
 | 
			
		||||
  const url = new URL('v1/user/login', SEASONED_URL)
 | 
			
		||||
  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;
 | 
			
		||||
      else
 | 
			
		||||
        console.error("Error occured when trying to sign in.\nError:", resp);
 | 
			
		||||
    })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const getSettings = () => {
 | 
			
		||||
  const settingsExists = (value) => {
 | 
			
		||||
    if (value instanceof Object && value.hasOwnProperty('settings'))
 | 
			
		||||
      return value;
 | 
			
		||||
    throw "Settings does not exist in response object.";
 | 
			
		||||
  }
 | 
			
		||||
  const commitSettingsToStore = (response) => {
 | 
			
		||||
    store.dispatch('userModule/setSettings', response.settings)
 | 
			
		||||
    return response
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const url = new URL('v1/user/settings', SEASONED_URL)
 | 
			
		||||
 | 
			
		||||
  const authorization_token = localStorage.getItem('token')
 | 
			
		||||
  const headers = authorization_token ? {
 | 
			
		||||
    'Authorization': authorization_token,
 | 
			
		||||
    'Content-Type': 'application/json'
 | 
			
		||||
  } : {}
 | 
			
		||||
 | 
			
		||||
  return fetch(url.href, { headers })
 | 
			
		||||
    .then(resp => resp.json())
 | 
			
		||||
    .then(settingsExists)
 | 
			
		||||
    .then(commitSettingsToStore)
 | 
			
		||||
    .then(response => response.settings)
 | 
			
		||||
    .catch(error => { console.log('api error getting user settings'); throw error })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const updateSettings = (settings) => {
 | 
			
		||||
  const url = new URL('v1/user/settings', SEASONED_URL)
 | 
			
		||||
 | 
			
		||||
  const authorization_token = localStorage.getItem('token')
 | 
			
		||||
  const headers = authorization_token ? {
 | 
			
		||||
    'Authorization': authorization_token,
 | 
			
		||||
    'Content-Type': 'application/json'
 | 
			
		||||
  } : {}
 | 
			
		||||
 | 
			
		||||
  return fetch(url.href, {
 | 
			
		||||
      method: 'PUT',
 | 
			
		||||
      headers,
 | 
			
		||||
      body: JSON.stringify(settings)
 | 
			
		||||
    })
 | 
			
		||||
    .then(resp => resp.json())
 | 
			
		||||
    .catch(error => { console.log('api error updating user settings'); throw error })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// - - - Authenticate with plex - - -
 | 
			
		||||
 | 
			
		||||
const linkPlexAccount = (username, password) => {
 | 
			
		||||
  const url = new URL('v1/user/link_plex', SEASONED_URL)
 | 
			
		||||
  const body = { username, password }
 | 
			
		||||
  const headers = {
 | 
			
		||||
    'Content-Type': 'application/json',
 | 
			
		||||
    authorization: storage.token
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return fetch(url.href, {
 | 
			
		||||
    method: 'POST',
 | 
			
		||||
    headers,
 | 
			
		||||
    body: JSON.stringify(body)
 | 
			
		||||
  })
 | 
			
		||||
  .then(resp => resp.json())
 | 
			
		||||
  .catch(error => { console.error(`api error linking plex account: ${username}`); throw error })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const unlinkPlexAccount = (username, password) => {
 | 
			
		||||
  const url = new URL('v1/user/unlink_plex', SEASONED_URL)
 | 
			
		||||
  const headers = {
 | 
			
		||||
    'Content-Type': 'application/json',
 | 
			
		||||
    authorization: storage.token
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return fetch(url.href, {
 | 
			
		||||
    method: 'POST',
 | 
			
		||||
    headers
 | 
			
		||||
  })
 | 
			
		||||
  .then(resp => resp.json())
 | 
			
		||||
  .catch(error => { console.error(`api error unlinking plex account: ${username}`); throw error })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// - - - User graphs - - -
 | 
			
		||||
 | 
			
		||||
const fetchChart = (urlPath, days, chartType) => {
 | 
			
		||||
  const url = new URL('v1/user' + urlPath, SEASONED_URL)
 | 
			
		||||
  url.searchParams.append('days', days)
 | 
			
		||||
  url.searchParams.append('y_axis', chartType)
 | 
			
		||||
 | 
			
		||||
  const authorization_token = localStorage.getItem('token')
 | 
			
		||||
  const headers = authorization_token ? {
 | 
			
		||||
    'Authorization': authorization_token,
 | 
			
		||||
    'Content-Type': 'application/json'
 | 
			
		||||
  } : {}
 | 
			
		||||
 | 
			
		||||
  return fetch(url.href, { headers })
 | 
			
		||||
    .then(resp => resp.json())
 | 
			
		||||
    .catch(error => { console.log('api error fetching chart'); 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,
 | 
			
		||||
  getPerson,
 | 
			
		||||
  getTmdbMovieListByName,
 | 
			
		||||
  searchTmdb,
 | 
			
		||||
  getUserRequests,
 | 
			
		||||
  getRequests,
 | 
			
		||||
  searchTorrents,
 | 
			
		||||
  addMagnet,
 | 
			
		||||
  request,
 | 
			
		||||
  watchLink,
 | 
			
		||||
  getRequestStatus,
 | 
			
		||||
  linkPlexAccount,
 | 
			
		||||
  unlinkPlexAccount,
 | 
			
		||||
  register,
 | 
			
		||||
  login,
 | 
			
		||||
  getSettings,
 | 
			
		||||
  updateSettings,
 | 
			
		||||
  fetchChart,
 | 
			
		||||
  getEmoji,
 | 
			
		||||
  elasticSearchMoviesAndShows
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										536
									
								
								src/api.ts
									
									
									
									
									
								
							
							
						
						@@ -1,536 +0,0 @@
 | 
			
		||||
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.0 MiB After Width: | Height: | Size: 1.0 MiB  | 
| 
		 Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/no-image.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.9 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  | 
							
								
								
									
										75
									
								
								src/components/404.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,75 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div>
 | 
			
		||||
    <section class="not-found">
 | 
			
		||||
      <h1 class="not-found__title">Page Not Found</h1>
 | 
			
		||||
    </section>
 | 
			
		||||
    <seasoned-button class="button" @click="goBack">go back to previous page</seasoned-button>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
import store from '@/store'
 | 
			
		||||
import SeasonedButton from '@/components/ui/SeasonedButton'
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  components: { SeasonedButton },
 | 
			
		||||
  methods: {
 | 
			
		||||
    goBack() {
 | 
			
		||||
      this.$router.go(-1)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  created() {
 | 
			
		||||
    if (this.$popup.isOpen == true)
 | 
			
		||||
      this.$popup.close()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
@import "./src/scss/variables";
 | 
			
		||||
@import "./src/scss/media-queries";
 | 
			
		||||
 | 
			
		||||
.button {
 | 
			
		||||
  font-size: 1.2rem;
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  left: calc(50% + 46px);
 | 
			
		||||
  transform: translate(-50%, -50%);
 | 
			
		||||
 | 
			
		||||
  @include mobile {
 | 
			
		||||
    top: 60%;
 | 
			
		||||
    left: 50%;
 | 
			
		||||
    font-size: 1rem;
 | 
			
		||||
    width: content;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.not-found {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  height: calc(100vh - var(--header-size));
 | 
			
		||||
  background: url('~assets/pulp-fiction.jpg') no-repeat 50% 50%;
 | 
			
		||||
  background-size: cover;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
 | 
			
		||||
  &::before {
 | 
			
		||||
    content: "";
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    height: calc(100vh - var(--header-size));
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    pointer-events: none;
 | 
			
		||||
    background: $background-40;
 | 
			
		||||
  }
 | 
			
		||||
  &__title {
 | 
			
		||||
    margin-top: 30vh;
 | 
			
		||||
    font-size: 2.5rem;
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
    color: $text-color;
 | 
			
		||||
    position: relative;
 | 
			
		||||
 | 
			
		||||
    @include tablet-min {
 | 
			
		||||
      font-size: 3.5rem;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										316
									
								
								src/components/ActivityPage.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,316 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="wrapper" v-if="hasPlexUser">
 | 
			
		||||
    <h1>Your watch activity</h1>
 | 
			
		||||
 | 
			
		||||
    <div class="filter">
 | 
			
		||||
      <h2>Filter</h2>
 | 
			
		||||
 | 
			
		||||
      <div class="filter-item">
 | 
			
		||||
        <label class="desktop-only">Days:</label>
 | 
			
		||||
        <input class="dayinput"
 | 
			
		||||
               v-model="days"
 | 
			
		||||
               placeholder="number of days"
 | 
			
		||||
               type="number"
 | 
			
		||||
               pattern="[0-9]*"
 | 
			
		||||
               :style="{maxWidth: `${3 + (0.5 * days.length)}rem`}"/>
 | 
			
		||||
<!--         <datalist id="days">
 | 
			
		||||
          <option v-for="index in 1500" :value="index" :key="index"></option>
 | 
			
		||||
        </datalist> -->
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <toggle-button class="filter-item" :options="chartTypes" :selected.sync="selectedChartDataType" />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="chart-section">
 | 
			
		||||
      <h3 class="chart-header">Activity per day:</h3>
 | 
			
		||||
      <div class="chart">
 | 
			
		||||
        <canvas ref="activityCanvas"></canvas>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <h3 class="chart-header">Activity per day of week:</h3>
 | 
			
		||||
      <div class="chart">
 | 
			
		||||
        <canvas ref="playsByDayOfWeekCanvas"></canvas>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div v-else>
 | 
			
		||||
    <h1>Must be authenticated</h1>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
import store from '@/store'
 | 
			
		||||
import ToggleButton from '@/components/ui/ToggleButton';
 | 
			
		||||
import { fetchChart } from '@/api'
 | 
			
		||||
 | 
			
		||||
var Chart = require('chart.js');
 | 
			
		||||
Chart.defaults.global.elements.point.radius = 0
 | 
			
		||||
Chart.defaults.global.elements.point.hitRadius = 10
 | 
			
		||||
Chart.defaults.global.elements.point.pointHoverRadius = 10
 | 
			
		||||
Chart.defaults.global.elements.point.hoverBorderWidth = 4
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  components: { ToggleButton },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      days: 30,
 | 
			
		||||
      selectedChartDataType: 'plays',
 | 
			
		||||
      charts: [{
 | 
			
		||||
        name: 'Watch activity',
 | 
			
		||||
        ref: 'activityCanvas',
 | 
			
		||||
        data: null,
 | 
			
		||||
        urlPath: '/plays_by_day',
 | 
			
		||||
        graphType: 'line'
 | 
			
		||||
      }, {
 | 
			
		||||
        name: 'Plays by day of week',
 | 
			
		||||
        ref: 'playsByDayOfWeekCanvas',
 | 
			
		||||
        data: null,
 | 
			
		||||
        urlPath: '/plays_by_dayofweek',
 | 
			
		||||
        graphType: 'bar'
 | 
			
		||||
      }],
 | 
			
		||||
      chartData: [{
 | 
			
		||||
        type: 'plays',
 | 
			
		||||
        tooltipLabel: 'Play count',
 | 
			
		||||
      },{
 | 
			
		||||
        type: 'duration',
 | 
			
		||||
        tooltipLabel: 'Watched duration',
 | 
			
		||||
        valueConvertFunction: this.convertSecondsToHumanReadable
 | 
			
		||||
      }],
 | 
			
		||||
      gridColor: getComputedStyle(document.documentElement).getPropertyValue('--text-color-5')
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    hasPlexUser() {
 | 
			
		||||
      return store.getters['userModule/plex_userid'] != null ? true : false
 | 
			
		||||
    },
 | 
			
		||||
    chartTypes() {
 | 
			
		||||
      return this.chartData.map(chart => chart.type)
 | 
			
		||||
    },
 | 
			
		||||
    selectedChartType() {
 | 
			
		||||
      return this.chartData.filter(data => data.type == this.selectedChartDataType)[0]
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    hasPlexUser(newValue, oldValue) {
 | 
			
		||||
      if (newValue != oldValue && newValue == true) {
 | 
			
		||||
        this.fetchChartData(this.charts)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    days(newValue) {
 | 
			
		||||
      if (newValue !== '') {
 | 
			
		||||
        this.fetchChartData(this.charts)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    selectedChartDataType(selectedChartDataType) {
 | 
			
		||||
      this.fetchChartData(this.charts)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  beforeMount() {
 | 
			
		||||
    if (typeof(this.days) == 'number') {
 | 
			
		||||
      this.days = this.days.toString()
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    fetchChartData(charts) {
 | 
			
		||||
      if (this.hasPlexUser == false) {
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      for (let chart of charts) {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        fetchChart(chart.urlPath, this.days, this.selectedChartType.type)
 | 
			
		||||
          .then(data => {
 | 
			
		||||
            this.series = data.data.series.filter(group => group.name === 'TV')[0].data;      // plays pr date in groups (movie/tv/music)
 | 
			
		||||
            this.categories = data.data.categories;  // dates
 | 
			
		||||
 | 
			
		||||
            const x_labels = data.data.categories.map(date => {
 | 
			
		||||
              if (date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
 | 
			
		||||
                const [year, month, day] = date.split('-')
 | 
			
		||||
                return `${day}.${month}`
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              return date
 | 
			
		||||
            })
 | 
			
		||||
            let y_activityMovies = data.data.series.filter(group => group.name === 'Movies')[0].data
 | 
			
		||||
            let y_activityTV = data.data.series.filter(group => group.name === 'TV')[0].data
 | 
			
		||||
 | 
			
		||||
            const datasets = [{
 | 
			
		||||
                label: `Movies watch last ${ this.days } days`,
 | 
			
		||||
                data: y_activityMovies,
 | 
			
		||||
                backgroundColor: 'rgba(54, 162, 235, 0.2)',
 | 
			
		||||
                borderColor: 'rgba(54, 162, 235, 1)',
 | 
			
		||||
                borderWidth: 1
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                label: `Shows watch last ${ this.days } days`,
 | 
			
		||||
                data: y_activityTV,
 | 
			
		||||
                backgroundColor: 'rgba(255, 159, 64, 0.2)',
 | 
			
		||||
                borderColor: 'rgba(255, 159, 64, 1)',
 | 
			
		||||
                borderWidth: 1
 | 
			
		||||
              }
 | 
			
		||||
            ]
 | 
			
		||||
 | 
			
		||||
            if (chart.data == null) {
 | 
			
		||||
              this.generateChart(chart, x_labels, datasets)
 | 
			
		||||
            } else {
 | 
			
		||||
              chart.data.clear();
 | 
			
		||||
              chart.data.data.labels = x_labels;
 | 
			
		||||
              chart.data.data.datasets = datasets;
 | 
			
		||||
              chart.data.update();
 | 
			
		||||
            }
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    generateChart(chart, labels, datasets) {
 | 
			
		||||
      const chartInstance = new Chart(this.$refs[chart.ref], {
 | 
			
		||||
        type: chart.graphType,
 | 
			
		||||
        data: {
 | 
			
		||||
            labels: labels,
 | 
			
		||||
            datasets: datasets
 | 
			
		||||
        },
 | 
			
		||||
        options: {
 | 
			
		||||
          // hitRadius: 8,
 | 
			
		||||
          maintainAspectRatio: false,
 | 
			
		||||
          tooltips: {
 | 
			
		||||
            callbacks: {
 | 
			
		||||
              title: (tooltipItem, data) => `Watch date: ${tooltipItem[0].label}`,
 | 
			
		||||
              label: (tooltipItem, data) => {
 | 
			
		||||
                let label = data.datasets[tooltipItem.datasetIndex].label
 | 
			
		||||
                let value = tooltipItem.value;
 | 
			
		||||
                let text = 'Duration watched'
 | 
			
		||||
 | 
			
		||||
                const context = label.split(' ')[0]
 | 
			
		||||
                if (context) {
 | 
			
		||||
                  text = `${context} ${this.selectedChartType.tooltipLabel.toLowerCase()}`
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                if (this.selectedChartType.valueConvertFunction) {
 | 
			
		||||
                  value = this.selectedChartType.valueConvertFunction(tooltipItem.value)
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                return ` ${text}: ${value}`
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          scales: {
 | 
			
		||||
              yAxes: [{
 | 
			
		||||
                gridLines: {
 | 
			
		||||
                    color: this.gridColor
 | 
			
		||||
                },
 | 
			
		||||
                stacked: chart.graphType === 'bar',
 | 
			
		||||
                ticks: {
 | 
			
		||||
                  // suggestedMax: 10000,
 | 
			
		||||
                  callback: (value, index, values) => {
 | 
			
		||||
                    if (this.selectedChartType.valueConvertFunction) {
 | 
			
		||||
                      return this.selectedChartType.valueConvertFunction(value, values)
 | 
			
		||||
                    }
 | 
			
		||||
                    return value
 | 
			
		||||
                  },
 | 
			
		||||
                  beginAtZero: true
 | 
			
		||||
                }
 | 
			
		||||
              }],
 | 
			
		||||
              xAxes: [{
 | 
			
		||||
                stacked: chart.graphType === 'bar',
 | 
			
		||||
                gridLines: {
 | 
			
		||||
                  display: false,
 | 
			
		||||
                }
 | 
			
		||||
              }]
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      chart.data = chartInstance;
 | 
			
		||||
    },
 | 
			
		||||
    convertSecondsToHumanReadable(value, values=null) {
 | 
			
		||||
      const highestValue = values ? values[0] : value;
 | 
			
		||||
 | 
			
		||||
      // minutes
 | 
			
		||||
      if (highestValue < 3600) {
 | 
			
		||||
        const minutes = Math.floor(value / 60);
 | 
			
		||||
 | 
			
		||||
        value = `${minutes} m`
 | 
			
		||||
      }
 | 
			
		||||
      // hours and minutes
 | 
			
		||||
      else if (highestValue > 3600 && highestValue < 86400) {
 | 
			
		||||
        const hours = Math.floor(value / 3600);
 | 
			
		||||
        const minutes = Math.floor(value % 3600 / 60);
 | 
			
		||||
 | 
			
		||||
        value = hours != 0 ? `${hours} h ${minutes} m` : `${minutes} m`
 | 
			
		||||
      }
 | 
			
		||||
      // days and hours
 | 
			
		||||
      else if (highestValue > 86400 && highestValue < 31557600) {
 | 
			
		||||
        const days = Math.floor(value / 86400);
 | 
			
		||||
        const hours = Math.floor(value % 86400 / 3600);
 | 
			
		||||
 | 
			
		||||
        value = days != 0 ? `${days} d ${hours} h` : `${hours} h`
 | 
			
		||||
      }
 | 
			
		||||
      // years and days
 | 
			
		||||
      else if (highestValue > 31557600) {
 | 
			
		||||
        const years = Math.floor(value / 31557600);
 | 
			
		||||
        const days = Math.floor(value % 31557600 / 86400);
 | 
			
		||||
 | 
			
		||||
        value = years != 0 ? `${years} y ${days} d` : `${days} d`
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return value
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
@import "./src/scss/variables";
 | 
			
		||||
 | 
			
		||||
.wrapper {
 | 
			
		||||
  padding: 2rem;
 | 
			
		||||
 | 
			
		||||
  @include mobile-only {
 | 
			
		||||
    padding: 0 0.8rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filter {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  margin-bottom: 2rem;
 | 
			
		||||
 | 
			
		||||
  h2 {
 | 
			
		||||
    margin-bottom: 0.5rem;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    font-weight: 400;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  &-item:not(:first-of-type) {
 | 
			
		||||
    margin-left: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .dayinput {
 | 
			
		||||
    font-size: 1.2rem;
 | 
			
		||||
    max-width: 3rem;
 | 
			
		||||
    background-color: $background-ui;
 | 
			
		||||
    color: $text-color;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.chart-section {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
 | 
			
		||||
  .chart {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    height: 35vh;
 | 
			
		||||
    width: 90vw;
 | 
			
		||||
    margin-bottom: 2rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .chart-header {
 | 
			
		||||
    font-weight: 300;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,51 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,117 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,170 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
							
								
								
									
										87
									
								
								src/components/Home.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,87 @@
 | 
			
		||||
<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: "/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,185 +1,63 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <header ref="headerElement" :class="{ expanded, noselect: true }">
 | 
			
		||||
    <img ref="imageElement" :src="bannerImage" alt="Page banner image" />
 | 
			
		||||
  <header v-bind:style="{ 'background-image': 'url(' + imageFile + ')' }">
 | 
			
		||||
    <div class="container">
 | 
			
		||||
      <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 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;
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    image: {
 | 
			
		||||
      type: String,
 | 
			
		||||
      required: false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    headerElement.value.style.setProperty("--header-height", height);
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      imageFile: "/pulp-fiction.jpg"
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  beforeMount() {
 | 
			
		||||
    if (this.image && this.image.length > 0) {
 | 
			
		||||
      this.imageFile = this.image;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  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 {
 | 
			
		||||
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;
 | 
			
		||||
    transition: height 0.5s ease;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    --header-height: 25vh;
 | 
			
		||||
 | 
			
		||||
    height: var(--header-height);
 | 
			
		||||
 | 
			
		||||
    > * {
 | 
			
		||||
      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;
 | 
			
		||||
      }
 | 
			
		||||
  @include tablet-min {
 | 
			
		||||
    height: 284px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &:before {
 | 
			
		||||
    content: "";
 | 
			
		||||
      z-index: 1;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
      background-color: var(--background-70);
 | 
			
		||||
      transition: inherit;
 | 
			
		||||
    background-color: $background-70;
 | 
			
		||||
    transition: background-color 0.5s ease;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .container {
 | 
			
		||||
@@ -195,7 +73,6 @@
 | 
			
		||||
    letter-spacing: 0.5px;
 | 
			
		||||
    color: $text-color;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
      opacity: 1;
 | 
			
		||||
 | 
			
		||||
    @include tablet-min {
 | 
			
		||||
      font-size: 2.5rem;
 | 
			
		||||
@@ -208,11 +85,10 @@
 | 
			
		||||
    font-weight: 300;
 | 
			
		||||
    color: $text-color-70;
 | 
			
		||||
    margin: 5px 0;
 | 
			
		||||
      opacity: 1;
 | 
			
		||||
 | 
			
		||||
    @include tablet-min {
 | 
			
		||||
      font-size: 1.3rem;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										117
									
								
								src/components/ListHeader.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,117 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <header :class="{ sticky: sticky }">
 | 
			
		||||
    <h2>{{ title }}</h2>
 | 
			
		||||
 | 
			
		||||
    <div v-if="info instanceof Array" class="flex flex-direction-column">
 | 
			
		||||
      <span v-for="item in info" class="info">{{ item }}</span>
 | 
			
		||||
    </div>
 | 
			
		||||
    <span v-else class="info">{{ info }}</span>
 | 
			
		||||
    <router-link
 | 
			
		||||
      v-if="link"
 | 
			
		||||
      :to="link"
 | 
			
		||||
      class="view-more"
 | 
			
		||||
      :aria-label="`View all ${title}`"
 | 
			
		||||
    >
 | 
			
		||||
      View All
 | 
			
		||||
    </router-link>
 | 
			
		||||
  </header>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    title: {
 | 
			
		||||
      type: String,
 | 
			
		||||
      required: true
 | 
			
		||||
    },
 | 
			
		||||
    sticky: {
 | 
			
		||||
      type: Boolean,
 | 
			
		||||
      required: false,
 | 
			
		||||
      default: true
 | 
			
		||||
    },
 | 
			
		||||
    info: {
 | 
			
		||||
      type: [String, Array],
 | 
			
		||||
      required: false
 | 
			
		||||
    },
 | 
			
		||||
    link: {
 | 
			
		||||
      type: String,
 | 
			
		||||
      required: false
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
@import "./src/scss/variables";
 | 
			
		||||
@import "./src/scss/media-queries";
 | 
			
		||||
@import "./src/scss/main";
 | 
			
		||||
 | 
			
		||||
header {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  min-height: 45px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  padding-left: 0.75rem;
 | 
			
		||||
  padding-right: 0.75rem;
 | 
			
		||||
 | 
			
		||||
  @include tablet-min {
 | 
			
		||||
    min-height: 65px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.sticky {
 | 
			
		||||
    background-color: $background-color;
 | 
			
		||||
 | 
			
		||||
    position: sticky;
 | 
			
		||||
    position: -webkit-sticky;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    z-index: 4;
 | 
			
		||||
 | 
			
		||||
    @include tablet-min {
 | 
			
		||||
      top: $header-size;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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>
 | 
			
		||||
							
								
								
									
										123
									
								
								src/components/ListPage.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,123 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="page-container">
 | 
			
		||||
    <list-header :title="listTitle" :info="info" :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,
 | 
			
		||||
      loading: true
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  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;
 | 
			
		||||
    },
 | 
			
		||||
    info() {
 | 
			
		||||
      if (this.results.length === 0) return [null, null];
 | 
			
		||||
      return [this.pageCount, this.resultCount];
 | 
			
		||||
    },
 | 
			
		||||
    resultCount() {
 | 
			
		||||
      const loadedResults = this.results.length;
 | 
			
		||||
      const totalResults = this.totalResults < 10000 ? this.totalResults : "∞";
 | 
			
		||||
      return `${loadedResults} of ${totalResults} results`;
 | 
			
		||||
    },
 | 
			
		||||
    pageCount() {
 | 
			
		||||
      return `Page ${this.page} of ${this.totalPages}`;
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    loadMore() {
 | 
			
		||||
      console.log(this.$route);
 | 
			
		||||
      this.loading = true;
 | 
			
		||||
      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");
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.loading = false;
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  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>
 | 
			
		||||
@import "./src/scss/media-queries";
 | 
			
		||||
 | 
			
		||||
@include mobile-only {
 | 
			
		||||
  .page-container {
 | 
			
		||||
    margin-top: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fullwidth-button {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  margin: 1rem 0;
 | 
			
		||||
  padding-bottom: 2rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										556
									
								
								src/components/Movie.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,556 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <section class="movie">
 | 
			
		||||
    <!-- HEADER w/ POSTER -->
 | 
			
		||||
    <header
 | 
			
		||||
      ref="header"
 | 
			
		||||
      :class="compact ? 'compact' : ''"
 | 
			
		||||
      @click="compact = !compact"
 | 
			
		||||
    >
 | 
			
		||||
      <figure class="movie__poster">
 | 
			
		||||
        <img
 | 
			
		||||
          class="movie-item__img is-loaded"
 | 
			
		||||
          ref="poster-image"
 | 
			
		||||
          src="~assets/placeholder.png"
 | 
			
		||||
        />
 | 
			
		||||
      </figure>
 | 
			
		||||
 | 
			
		||||
      <h1 class="movie__title" v-if="movie">{{ movie.title }}</h1>
 | 
			
		||||
      <loading-placeholder v-else :count="1" />
 | 
			
		||||
    </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="isPlexAuthenticated && matched"
 | 
			
		||||
            @click="openInPlex"
 | 
			
		||||
            :iconString="'⏯ '"
 | 
			
		||||
          >
 | 
			
		||||
            Watch in plex now!
 | 
			
		||||
          </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">
 | 
			
		||||
          <!-- Loading placeholder -->
 | 
			
		||||
          <div
 | 
			
		||||
            class="movie__description noselect"
 | 
			
		||||
            @click="truncatedDescription = !truncatedDescription"
 | 
			
		||||
            v-if="!loading"
 | 
			
		||||
          >
 | 
			
		||||
            <span :class="truncatedDescription ? 'truncated' : null">{{
 | 
			
		||||
              movie.overview
 | 
			
		||||
            }}</span>
 | 
			
		||||
            <button class="truncate-toggle"><i>⬆</i></button>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div v-else class="movie__description">
 | 
			
		||||
            <loading-placeholder :count="5" />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="movie__details" v-if="movie">
 | 
			
		||||
            <div v-if="movie.year">
 | 
			
		||||
              <h2 class="title">Release Date</h2>
 | 
			
		||||
              <div class="text">{{ movie.year }}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div v-if="movie.rating">
 | 
			
		||||
              <h2 class="title">Rating</h2>
 | 
			
		||||
              <div class="text">{{ movie.rating }}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div v-if="movie.type == 'show'">
 | 
			
		||||
              <h2 class="title">Seasons</h2>
 | 
			
		||||
              <div class="text">{{ movie.seasons }}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div v-if="movie.genres">
 | 
			
		||||
              <h2 class="title">Genres</h2>
 | 
			
		||||
              <div class="text">{{ movie.genres.join(", ") }}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div v-if="movie.type == 'show'">
 | 
			
		||||
              <h2 class="title">Production status</h2>
 | 
			
		||||
              <div class="text">{{ movie.production_status }}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div v-if="movie.type == 'show'">
 | 
			
		||||
              <h2 class="title">Runtime</h2>
 | 
			
		||||
              <div class="text">{{ movie.runtime[0] }} minutes</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,
 | 
			
		||||
  getPerson,
 | 
			
		||||
  getShow,
 | 
			
		||||
  request,
 | 
			
		||||
  getRequestStatus,
 | 
			
		||||
  watchLink
 | 
			
		||||
} from "@/api";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  // props: ['id', 'type'],
 | 
			
		||||
  props: {
 | 
			
		||||
    id: {
 | 
			
		||||
      required: true,
 | 
			
		||||
      type: Number
 | 
			
		||||
    },
 | 
			
		||||
    type: {
 | 
			
		||||
      required: false,
 | 
			
		||||
      type: String
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  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") == "true" ? true : false,
 | 
			
		||||
      showTorrents: false,
 | 
			
		||||
      compact: false,
 | 
			
		||||
      loading: true,
 | 
			
		||||
      truncatedDescription: true
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    id: function (val) {
 | 
			
		||||
      if (this.type === "movie") {
 | 
			
		||||
        this.fetchMovie(val);
 | 
			
		||||
      } else {
 | 
			
		||||
        this.fetchShow(val);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    backdrop: function (backdrop) {
 | 
			
		||||
      if (backdrop != null) {
 | 
			
		||||
        const style = {
 | 
			
		||||
          backgroundImage:
 | 
			
		||||
            "url(" + this.ASSET_URL + this.ASSET_SIZES[1] + backdrop + ")"
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        Object.assign(this.$refs.header.style, style);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    numberOfTorrentResults: () => {
 | 
			
		||||
      let numTorrents = store.getters["torrentModule/resultCount"];
 | 
			
		||||
      return numTorrents !== null ? numTorrents + " results" : null;
 | 
			
		||||
    },
 | 
			
		||||
    isPlexAuthenticated: () => {
 | 
			
		||||
      return store.getters["userModule/isPlexAuthenticated"];
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    parseResponse(movie) {
 | 
			
		||||
      this.loading = false;
 | 
			
		||||
      this.movie = { ...movie };
 | 
			
		||||
      this.title = movie.title;
 | 
			
		||||
      this.poster = movie.poster;
 | 
			
		||||
      this.backdrop = movie.backdrop;
 | 
			
		||||
      this.matched = movie.exists_in_plex || false;
 | 
			
		||||
      this.checkIfRequested(movie).then(status => (this.requested = status));
 | 
			
		||||
 | 
			
		||||
      store.dispatch("documentTitle/updateTitle", movie.title);
 | 
			
		||||
      this.setPosterSrc();
 | 
			
		||||
    },
 | 
			
		||||
    async checkIfRequested(movie) {
 | 
			
		||||
      return await getRequestStatus(movie.id, movie.type);
 | 
			
		||||
    },
 | 
			
		||||
    setPosterSrc() {
 | 
			
		||||
      const poster = this.$refs["poster-image"];
 | 
			
		||||
      if (this.poster == null) {
 | 
			
		||||
        poster.src = "/no-image.png";
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      poster.src = `${this.ASSET_URL}${this.ASSET_SIZES[0]}${this.poster}`;
 | 
			
		||||
    },
 | 
			
		||||
    sendRequest() {
 | 
			
		||||
      request(this.id, this.type, storage.token).then(resp => {
 | 
			
		||||
        if (resp.success) {
 | 
			
		||||
          this.requested = true;
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    openInPlex() {
 | 
			
		||||
      watchLink(this.title, this.movie.year, storage.token).then(
 | 
			
		||||
        watchLink => (window.location = watchLink)
 | 
			
		||||
      );
 | 
			
		||||
    },
 | 
			
		||||
    openTmdb() {
 | 
			
		||||
      const tmdbType = this.type === "show" ? "tv" : this.type;
 | 
			
		||||
      window.location.href =
 | 
			
		||||
        "https://www.themoviedb.org/" + tmdbType + "/" + this.id;
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  created() {
 | 
			
		||||
    this.prevDocumentTitle = store.getters["documentTitle/title"];
 | 
			
		||||
 | 
			
		||||
    if (this.type === "movie") {
 | 
			
		||||
      getMovie(this.id, true)
 | 
			
		||||
        .then(this.parseResponse)
 | 
			
		||||
        .catch(error => {
 | 
			
		||||
          this.$router.push({ name: "404" });
 | 
			
		||||
        });
 | 
			
		||||
    } else if (this.type == "person") {
 | 
			
		||||
      getPerson(this.id, true)
 | 
			
		||||
        .then(this.parseResponse)
 | 
			
		||||
        .catch(error => {
 | 
			
		||||
          this.$router.push({ name: "404" });
 | 
			
		||||
        });
 | 
			
		||||
    } else {
 | 
			
		||||
      getShow(this.id, true)
 | 
			
		||||
        .then(this.parseResponse)
 | 
			
		||||
        .catch(error => {
 | 
			
		||||
          this.$router.push({ name: "404" });
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  beforeDestroy() {
 | 
			
		||||
    store.dispatch("documentTitle/updateTitle", this.prevDocumentTitle);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</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;
 | 
			
		||||
  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;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
 | 
			
		||||
  @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;
 | 
			
		||||
  }
 | 
			
		||||
  @include mobile {
 | 
			
		||||
    &.compact {
 | 
			
		||||
      height: 100px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.movie__poster {
 | 
			
		||||
  display: none;
 | 
			
		||||
 | 
			
		||||
  @include desktop {
 | 
			
		||||
    background: $background-color;
 | 
			
		||||
    height: 0;
 | 
			
		||||
    display: block;
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    width: calc(45% - 40px);
 | 
			
		||||
    top: 40px;
 | 
			
		||||
    left: 40px;
 | 
			
		||||
 | 
			
		||||
    > img {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.truncate-toggle {
 | 
			
		||||
  border: none;
 | 
			
		||||
  background: none;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  color: $text-color;
 | 
			
		||||
 | 
			
		||||
  > i {
 | 
			
		||||
    font-style: unset;
 | 
			
		||||
    font-size: 0.7rem;
 | 
			
		||||
    transition: 0.3s ease all;
 | 
			
		||||
    transform: rotateY(180deg);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &::before,
 | 
			
		||||
  &::after {
 | 
			
		||||
    content: "";
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    border-bottom: 1px solid $text-color-50;
 | 
			
		||||
  }
 | 
			
		||||
  &::before {
 | 
			
		||||
    margin-right: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
  &::after {
 | 
			
		||||
    margin-left: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__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;
 | 
			
		||||
 | 
			
		||||
    & .truncated {
 | 
			
		||||
      display: -webkit-box;
 | 
			
		||||
      overflow: hidden;
 | 
			
		||||
      -webkit-line-clamp: 4;
 | 
			
		||||
      -webkit-box-orient: vertical;
 | 
			
		||||
 | 
			
		||||
      & + .truncate-toggle > i {
 | 
			
		||||
        transform: rotateY(0deg) rotateZ(180deg);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @include tablet-min {
 | 
			
		||||
      margin-bottom: 30px;
 | 
			
		||||
      font-size: 14px;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  &__details {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
 | 
			
		||||
    > div {
 | 
			
		||||
      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>
 | 
			
		||||
							
								
								
									
										12
									
								
								src/components/MoviePage.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,12 @@
 | 
			
		||||
<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>
 | 
			
		||||
							
								
								
									
										104
									
								
								src/components/MoviePopup.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,104 @@
 | 
			
		||||
<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)
 | 
			
		||||
    document.getElementsByTagName("body")[0].classList += " no-scroll";
 | 
			
		||||
  },
 | 
			
		||||
  beforeDestroy() {
 | 
			
		||||
    window.removeEventListener('keyup', this.checkEventForEscapeKey)
 | 
			
		||||
    document.getElementsByTagName("body")[0].classList.remove("no-scroll");
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
</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>
 | 
			
		||||
							
								
								
									
										249
									
								
								src/components/MoviesListItem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,249 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <li class="movie-item" :class="{ shortList: shortList }">
 | 
			
		||||
    <figure class="movie-item__poster">
 | 
			
		||||
      <img
 | 
			
		||||
        class="movie-item__img"
 | 
			
		||||
        ref="poster-image"
 | 
			
		||||
        @click="openMoviePopup(movie.id, movie.type)"
 | 
			
		||||
        :alt="posterAltText"
 | 
			
		||||
        :data-src="poster"
 | 
			
		||||
        src="~assets/placeholder.png"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <div v-if="movie.download" class="progress">
 | 
			
		||||
        <progress :value="movie.download.progress" max="100"></progress>
 | 
			
		||||
        <span>{{ movie.download.state }}: {{ movie.download.progress }}%</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </figure>
 | 
			
		||||
 | 
			
		||||
    <div class="movie-item__info">
 | 
			
		||||
      <p v-if="movie.title || movie.name">{{ movie.title || movie.name }}</p>
 | 
			
		||||
      <p v-if="movie.year">{{ movie.year }}</p>
 | 
			
		||||
      <p v-if="movie.type == 'person'">
 | 
			
		||||
        Known for: {{ movie.known_for_department }}
 | 
			
		||||
      </p>
 | 
			
		||||
    </div>
 | 
			
		||||
  </li>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
import img from "../directives/v-image";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    movie: {
 | 
			
		||||
      type: Object,
 | 
			
		||||
      required: true
 | 
			
		||||
    },
 | 
			
		||||
    shortList: {
 | 
			
		||||
      type: Boolean,
 | 
			
		||||
      required: false
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  directives: {
 | 
			
		||||
    img: img
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      poster: undefined,
 | 
			
		||||
      observed: false,
 | 
			
		||||
      posterSizes: [
 | 
			
		||||
        {
 | 
			
		||||
          id: "w500",
 | 
			
		||||
          minWidth: 500
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          id: "w342",
 | 
			
		||||
          minWidth: 342
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          id: "w185",
 | 
			
		||||
          minWidth: 185
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          id: "w154",
 | 
			
		||||
          minWidth: 0
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    posterAltText: function () {
 | 
			
		||||
      const type = this.movie.type || "";
 | 
			
		||||
      const title = this.movie.title || this.movie.name;
 | 
			
		||||
      return this.movie.poster
 | 
			
		||||
        ? `Poster for ${type} ${title}`
 | 
			
		||||
        : `Missing image for ${type} ${title}`;
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  beforeMount() {
 | 
			
		||||
    if (this.movie.poster != null) {
 | 
			
		||||
      this.poster = "https://image.tmdb.org/t/p/w500" + this.movie.poster;
 | 
			
		||||
    } else {
 | 
			
		||||
      this.poster = "/no-image.png";
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {
 | 
			
		||||
    const poster = this.$refs["poster-image"];
 | 
			
		||||
    if (poster == null) return;
 | 
			
		||||
 | 
			
		||||
    const imageObserver = new IntersectionObserver((entries, imgObserver) => {
 | 
			
		||||
      entries.forEach(entry => {
 | 
			
		||||
        if (entry.isIntersecting && this.observed == false) {
 | 
			
		||||
          const lazyImage = entry.target;
 | 
			
		||||
          lazyImage.src = lazyImage.dataset.src;
 | 
			
		||||
          lazyImage.className = lazyImage.className + " is-loaded";
 | 
			
		||||
          this.observed = true;
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    imageObserver.observe(poster);
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    openMoviePopup(id, type) {
 | 
			
		||||
      this.$popup.open(id, type);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
@import "./src/scss/variables";
 | 
			
		||||
@import "./src/scss/media-queries";
 | 
			
		||||
@import "./src/scss/main";
 | 
			
		||||
 | 
			
		||||
.movie-item {
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  width: 50%;
 | 
			
		||||
  background-color: $background-color;
 | 
			
		||||
  transition: background-color 0.5s ease;
 | 
			
		||||
 | 
			
		||||
  @include tablet-min {
 | 
			
		||||
    padding: 15px;
 | 
			
		||||
    width: 33%;
 | 
			
		||||
  }
 | 
			
		||||
  @include tablet-landscape-min {
 | 
			
		||||
    padding: 15px;
 | 
			
		||||
    width: 25%;
 | 
			
		||||
  }
 | 
			
		||||
  @include desktop-min {
 | 
			
		||||
    padding: 15px;
 | 
			
		||||
    width: 20%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @include desktop-lg-min {
 | 
			
		||||
    padding: 15px;
 | 
			
		||||
    width: 12.5%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &:hover &__info > p {
 | 
			
		||||
    color: $text-color;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__poster {
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    color: $text-color-70;
 | 
			
		||||
    font-weight: 300;
 | 
			
		||||
 | 
			
		||||
    > img {
 | 
			
		||||
      width: 100%;
 | 
			
		||||
      opacity: 0;
 | 
			
		||||
      transform: scale(0.97) translateZ(0);
 | 
			
		||||
      transition: opacity 1s ease, transform 0.5s ease;
 | 
			
		||||
      &.is-loaded {
 | 
			
		||||
        opacity: 1;
 | 
			
		||||
        transform: scale(1);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      &:hover {
 | 
			
		||||
        transform: scale(1.03);
 | 
			
		||||
        box-shadow: 0 0 10px rgba($dark, 0.1);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__info {
 | 
			
		||||
    padding-top: 15px;
 | 
			
		||||
    font-weight: 300;
 | 
			
		||||
 | 
			
		||||
    > p {
 | 
			
		||||
      color: $text-color-70;
 | 
			
		||||
      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;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.no-image {
 | 
			
		||||
  background-color: var(--text-color);
 | 
			
		||||
  color: var(--background-color);
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 383px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
 | 
			
		||||
  span {
 | 
			
		||||
    font-size: 1.5rem;
 | 
			
		||||
    width: 70%;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    text-transform: uppercase;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &:hover {
 | 
			
		||||
    transform: scale(1);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
@import "./src/scss/variables";
 | 
			
		||||
 | 
			
		||||
.progress {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  margin-bottom: 0.8rem;
 | 
			
		||||
 | 
			
		||||
  > progress {
 | 
			
		||||
    width: 95%;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  > span {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    font-size: 1rem;
 | 
			
		||||
    line-height: 1.4rem;
 | 
			
		||||
    color: $white;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  progress {
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
    height: 1.4rem;
 | 
			
		||||
  }
 | 
			
		||||
  progress::-webkit-progress-bar {
 | 
			
		||||
    background-color: rgba($black, 0.55);
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
  }
 | 
			
		||||
  progress::-webkit-progress-value {
 | 
			
		||||
    background-color: $green-70;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
  }
 | 
			
		||||
  progress::-moz-progress-bar {
 | 
			
		||||
    /* style rules */
 | 
			
		||||
    background-color: green;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										336
									
								
								src/components/Navigation.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,336 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <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 mobile-only"></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>
 | 
			
		||||
</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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav {
 | 
			
		||||
  transition: background 0.5s ease;
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: var(--header-size);
 | 
			
		||||
  z-index: 10;
 | 
			
		||||
  display: block;
 | 
			
		||||
  color: $text-color;
 | 
			
		||||
  background-color: $background-color-secondary;
 | 
			
		||||
 | 
			
		||||
  @include tablet-min {
 | 
			
		||||
    top: 0;
 | 
			
		||||
    bottom: unset;
 | 
			
		||||
    width: 95px;
 | 
			
		||||
    height: 100vh;
 | 
			
		||||
  }
 | 
			
		||||
  &__logo {
 | 
			
		||||
    width: 95px;
 | 
			
		||||
    height: $header-size;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    background: $background-nav-logo;
 | 
			
		||||
 | 
			
		||||
    @include mobile-only {
 | 
			
		||||
      align-items: flex-start;
 | 
			
		||||
      padding-top: 0.5rem;
 | 
			
		||||
      width: 55px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &-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;
 | 
			
		||||
    bottom: 1.5rem;
 | 
			
		||||
    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;
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      top: unset;
 | 
			
		||||
      bottom: var(--header-size);
 | 
			
		||||
      height: min-content;
 | 
			
		||||
      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 0.5s ease, color 0.5s ease, border 0.5s ease;
 | 
			
		||||
    background-color: $background-color-secondary;
 | 
			
		||||
    color: $text-color-70;
 | 
			
		||||
 | 
			
		||||
    @include mobile-only {
 | 
			
		||||
      flex: 0 0 33.3%;
 | 
			
		||||
      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>
 | 
			
		||||
@@ -1,110 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
							
								
								
									
										63
									
								
								src/components/Person.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,63 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,152 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
							
								
								
									
										150
									
								
								src/components/Profile.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,150 @@
 | 
			
		||||
<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="toggleSettings">{{ 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: '',
 | 
			
		||||
      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`
 | 
			
		||||
    },
 | 
			
		||||
    username: () => store.getters['userModule/username']
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    toggleSettings() {
 | 
			
		||||
      this.showSettings = this.showSettings ? false : true;
 | 
			
		||||
 | 
			
		||||
      if (this.showSettings) {
 | 
			
		||||
        this.$router.replace({ query: { settings: true} })
 | 
			
		||||
      } else {
 | 
			
		||||
        this.$router.replace({ name: 'profile' })
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    logOut(){
 | 
			
		||||
      this.$router.push('logout')
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  created(){
 | 
			
		||||
    if(!localStorage.getItem('token')){
 | 
			
		||||
      this.userLoggedIn = false;
 | 
			
		||||
    } else {
 | 
			
		||||
      this.userLoggedIn = true;
 | 
			
		||||
 | 
			
		||||
      this.showSettings = window.location.toString().includes('settings=true')
 | 
			
		||||
 | 
			
		||||
      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>
 | 
			
		||||
							
								
								
									
										110
									
								
								src/components/Register.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,110 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <section>
 | 
			
		||||
    <h1>Register new user</h1>
 | 
			
		||||
 | 
			
		||||
    <seasoned-input placeholder="username" icon="Email" type="username" :value.sync="username" @enter="submit"/>
 | 
			
		||||
 | 
			
		||||
    <seasoned-input placeholder="password" icon="Keyhole" type="password" :value.sync="password" @enter="submit"/>
 | 
			
		||||
    <seasoned-input placeholder="repeat password" icon="Keyhole" type="password" :value.sync="passwordRepeat" @enter="submit"/>
 | 
			
		||||
 | 
			
		||||
    <seasoned-button @click="submit">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 { register } from '@/api'
 | 
			
		||||
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: {
 | 
			
		||||
    submit() {
 | 
			
		||||
      this.messages = [];
 | 
			
		||||
      let { username, password, passwordRepeat } = this;
 | 
			
		||||
 | 
			
		||||
      if (username == null || username.length == 0) {
 | 
			
		||||
        this.messages.push({ type: 'error', title: 'Missing username' })
 | 
			
		||||
        return
 | 
			
		||||
      } else if (password == null || password.length == 0) {
 | 
			
		||||
        this.messages.push({ type: 'error', title: 'Missing password' })
 | 
			
		||||
        return
 | 
			
		||||
      } else if (passwordRepeat == null || passwordRepeat.length == 0) {
 | 
			
		||||
        this.messages.push({ type: 'error', title: 'Missing repeat password' })
 | 
			
		||||
        return
 | 
			
		||||
      } else if (passwordRepeat != password) {
 | 
			
		||||
        this.messages.push({ type: 'error', title: 'Passwords do not match' })
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.registerUser(username, password)
 | 
			
		||||
    },
 | 
			
		||||
    registerUser(username, password) {
 | 
			
		||||
      register(username, password, true)
 | 
			
		||||
        .then(data => {
 | 
			
		||||
          if (data.success){
 | 
			
		||||
            localStorage.setItem('token', data.token);
 | 
			
		||||
            const jwtData = parseJwt(data.token)
 | 
			
		||||
            localStorage.setItem('username', jwtData['username']);
 | 
			
		||||
            localStorage.setItem('admin', jwtData['admin'] || false);
 | 
			
		||||
 | 
			
		||||
            eventHub.$emit('setUserStatus');
 | 
			
		||||
            this.$router.push({ name: 'profile' })
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        .catch(error => {
 | 
			
		||||
          if (error.status === 401) {
 | 
			
		||||
            this.messages.push({ type: 'error', title: 'Access denied', message: 'Incorrect username or password' })
 | 
			
		||||
          }
 | 
			
		||||
          else {
 | 
			
		||||
            this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message })
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
    },
 | 
			
		||||
    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,77 +1,68 @@
 | 
			
		||||
<template> 
 | 
			
		||||
  <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 class="results" :class="{'shortList': shortList}">
 | 
			
		||||
    <movies-list-item v-for='movie in results' :movie="movie" />
 | 
			
		||||
  </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";
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    results: Array<ListResults>;
 | 
			
		||||
    shortList?: boolean;
 | 
			
		||||
    loading?: boolean;
 | 
			
		||||
<script>
 | 
			
		||||
import MoviesListItem from '@/components/MoviesListItem'
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  components: { MoviesListItem },
 | 
			
		||||
  props: {
 | 
			
		||||
    results: {
 | 
			
		||||
      type: Array,
 | 
			
		||||
      required: true
 | 
			
		||||
    },
 | 
			
		||||
    shortList: {
 | 
			
		||||
      type: Boolean,
 | 
			
		||||
      required: false,
 | 
			
		||||
      default: false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  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/main";
 | 
			
		||||
@import './src/scss/media-queries';
 | 
			
		||||
 | 
			
		||||
  .no-results {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    display: block;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    margin: 1.5rem;
 | 
			
		||||
    font-size: 1.2rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .results {
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
 | 
			
		||||
    grid-auto-rows: auto;
 | 
			
		||||
.results {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  list-style: none;
 | 
			
		||||
 | 
			
		||||
    @include mobile {
 | 
			
		||||
      grid-template-columns: repeat(2, 1fr);
 | 
			
		||||
    }
 | 
			
		||||
  &.shortList > li {
 | 
			
		||||
    display: none;
 | 
			
		||||
 | 
			
		||||
    &.shortList {
 | 
			
		||||
      overflow: auto;
 | 
			
		||||
      grid-auto-flow: column;
 | 
			
		||||
      max-width: 100vw;
 | 
			
		||||
    &:nth-child(-n+4) {
 | 
			
		||||
      display: block;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
      @include noscrollbar;
 | 
			
		||||
@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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
      > li {
 | 
			
		||||
        min-width: 225px;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      @include tablet-min {
 | 
			
		||||
        max-width: calc(100vw - var(--header-size));
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,183 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,203 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
							
								
								
									
										138
									
								
								src/components/Search.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,138 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="page-container">
 | 
			
		||||
    <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>
 | 
			
		||||
 | 
			
		||||
    <div class="notFound" v-if="results.length == 0 && loading == false">
 | 
			
		||||
      <h1 class="notFound-title">
 | 
			
		||||
        No results for search: <b>{{ query }}</b>
 | 
			
		||||
      </h1>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <loader v-if="loading" />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.notFound {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
 | 
			
		||||
  &-title {
 | 
			
		||||
    font-weight: 400;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 | 
			
		||||
<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,
 | 
			
		||||
      adult: undefined,
 | 
			
		||||
      mediaType: null,
 | 
			
		||||
      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,
 | 
			
		||||
      adult = this.adult,
 | 
			
		||||
      mediaType = this.mediaType
 | 
			
		||||
    ) {
 | 
			
		||||
      searchTmdb(query, page, adult, mediaType).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, adult, media_type } = this.$route.query;
 | 
			
		||||
 | 
			
		||||
    if (!query) {
 | 
			
		||||
      // abort
 | 
			
		||||
      console.error("abort, no query");
 | 
			
		||||
    }
 | 
			
		||||
    this.query = decodeURIComponent(query);
 | 
			
		||||
    this.page = page || 1;
 | 
			
		||||
    this.adult = adult || this.adult;
 | 
			
		||||
    this.mediaType = media_type || this.mediaType;
 | 
			
		||||
    this.title = `Search results: ${this.query}`;
 | 
			
		||||
 | 
			
		||||
    this.search();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
@import "./src/scss/media-queries";
 | 
			
		||||
 | 
			
		||||
@include mobile-only {
 | 
			
		||||
  .page-container {
 | 
			
		||||
    margin-top: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fullwidth-button {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  margin: 1rem 0;
 | 
			
		||||
  padding-bottom: 2rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										402
									
								
								src/components/SearchInput.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,402 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <!-- <div> -->
 | 
			
		||||
  <div class="search">
 | 
			
		||||
    <input
 | 
			
		||||
      ref="input"
 | 
			
		||||
      type="text"
 | 
			
		||||
      placeholder="Search for movie or show"
 | 
			
		||||
      aria-label="Search input for finding a movie or show"
 | 
			
		||||
      autocorrect="off"
 | 
			
		||||
      autocapitalize="off"
 | 
			
		||||
      tabindex="1"
 | 
			
		||||
      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" @click="handleSubmit">
 | 
			
		||||
      <use xlink:href="#iconSearch"></use>
 | 
			
		||||
    </svg>
 | 
			
		||||
  </div>
 | 
			
		||||
  <!-- 
 | 
			
		||||
    <transition name="fade">
 | 
			
		||||
      <div class="dropdown" v-if="!disabled && focus && query.length > 0">
 | 
			
		||||
        <div class="filter">
 | 
			
		||||
          <h2>Filter your search:</h2>
 | 
			
		||||
 | 
			
		||||
          <div class="filter-items">
 | 
			
		||||
            <toggle-button
 | 
			
		||||
              :options="searchTypes"
 | 
			
		||||
              :selected.sync="selectedSearchType"
 | 
			
		||||
            />
 | 
			
		||||
 | 
			
		||||
            <label
 | 
			
		||||
              >Adult
 | 
			
		||||
              <input type="checkbox" value="adult" v-model="adult" />
 | 
			
		||||
            </label>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <hr />
 | 
			
		||||
 | 
			
		||||
        <div class="dropdown-results" v-if="elasticSearchResults.length">
 | 
			
		||||
          <ul
 | 
			
		||||
            v-for="(item, index) in elasticSearchResults"
 | 
			
		||||
            @click="openResult(item, index + 1)"
 | 
			
		||||
            :class="{ active: index + 1 === selectedResult }"
 | 
			
		||||
          >
 | 
			
		||||
            {{
 | 
			
		||||
              item.name
 | 
			
		||||
            }}
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div v-else class="dropdown">
 | 
			
		||||
          <div class="dropdown-results">
 | 
			
		||||
            <h2 class="not-found">
 | 
			
		||||
              No results for query: <b>{{ query }}</b>
 | 
			
		||||
            </h2>
 | 
			
		||||
          </div>
 | 
			
		||||
        </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 ToggleButton from "@/components/ui/ToggleButton";
 | 
			
		||||
 | 
			
		||||
import { elasticSearchMoviesAndShows } from "@/api";
 | 
			
		||||
import config from "@/config.json";
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: "SearchInput",
 | 
			
		||||
  components: {
 | 
			
		||||
    SeasonedButton,
 | 
			
		||||
    ToggleButton
 | 
			
		||||
  },
 | 
			
		||||
  props: ["value"],
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      adult: true,
 | 
			
		||||
      searchTypes: ["all", "movie", "show", "person"],
 | 
			
		||||
      selectedSearchType: "all",
 | 
			
		||||
 | 
			
		||||
      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;
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    adult: function (value) {
 | 
			
		||||
      this.handleInput();
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  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--;
 | 
			
		||||
      const input = this.$refs.input;
 | 
			
		||||
      const textLength = input.value.length;
 | 
			
		||||
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        input.focus();
 | 
			
		||||
        input.setSelectionRange(textLength, textLength + 1);
 | 
			
		||||
      }, 1);
 | 
			
		||||
    },
 | 
			
		||||
    openResult(item, index) {
 | 
			
		||||
      this.selectedResult = index;
 | 
			
		||||
      this.$popup.open(item.id, item.type);
 | 
			
		||||
    },
 | 
			
		||||
    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;
 | 
			
		||||
 | 
			
		||||
        let results = 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,
 | 
			
		||||
              adult: item._source.adult,
 | 
			
		||||
              type: "movie"
 | 
			
		||||
            };
 | 
			
		||||
          } else if (index === "show" || item._source.original_name) {
 | 
			
		||||
            return {
 | 
			
		||||
              name: item._source.original_name,
 | 
			
		||||
              id: item._source.id,
 | 
			
		||||
              adult: item._source.adult,
 | 
			
		||||
              type: "show"
 | 
			
		||||
            };
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
        results = this.removeDuplicates(results);
 | 
			
		||||
        this.elasticSearchResults = results;
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    removeDuplicates(searchResults) {
 | 
			
		||||
      let filteredResults = [];
 | 
			
		||||
      searchResults.map(result => {
 | 
			
		||||
        const numberOfDuplicates = filteredResults.filter(
 | 
			
		||||
          filterItem => filterItem.id == result.id
 | 
			
		||||
        );
 | 
			
		||||
        if (numberOfDuplicates.length >= 1) {
 | 
			
		||||
          return null;
 | 
			
		||||
        }
 | 
			
		||||
        filteredResults.push(result);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (this.adult == false) {
 | 
			
		||||
        filteredResults = filteredResults.filter(
 | 
			
		||||
          result => result.adult == false
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return filteredResults;
 | 
			
		||||
    },
 | 
			
		||||
    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, "+"'));
 | 
			
		||||
        const media_type =
 | 
			
		||||
          this.selectedSearchType !== "all" ? this.selectedSearchType : null;
 | 
			
		||||
        this.$router.push({
 | 
			
		||||
          name: "search",
 | 
			
		||||
          query: { query: encodedQuery, adult: this.adult, media_type }
 | 
			
		||||
        });
 | 
			
		||||
        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 0.2s;
 | 
			
		||||
}
 | 
			
		||||
.fade-leave-active {
 | 
			
		||||
  transition: opacity 0.2s;
 | 
			
		||||
}
 | 
			
		||||
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.filter {
 | 
			
		||||
  // background-color: rgba(004, 122, 125, 0.2);
 | 
			
		||||
  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%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.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%);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .not-found {
 | 
			
		||||
    font-weight: 400;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &-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: 16;
 | 
			
		||||
  border: 0;
 | 
			
		||||
  background-color: $background-color-secondary;
 | 
			
		||||
 | 
			
		||||
  // TODO check if this is for mobile
 | 
			
		||||
  width: calc(100% - 110px);
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  right: 55px;
 | 
			
		||||
 | 
			
		||||
  @include tablet-min {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    right: 0px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  input {
 | 
			
		||||
    display: block;
 | 
			
		||||
    height: calc($header-size - 1.5rem);
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    padding: 13px 0 13px 45px;
 | 
			
		||||
    outline: none;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    margin-bottom: auto;
 | 
			
		||||
    border: 0;
 | 
			
		||||
    background-color: $background-color-secondary;
 | 
			
		||||
    font-weight: 300;
 | 
			
		||||
    font-size: 19px;
 | 
			
		||||
    color: $text-color;
 | 
			
		||||
    transition: background-color 0.5s ease, color 0.5s ease;
 | 
			
		||||
 | 
			
		||||
    @include tablet-min {
 | 
			
		||||
      height: calc($header-size);
 | 
			
		||||
      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>
 | 
			
		||||
							
								
								
									
										198
									
								
								src/components/Settings.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,198 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <section class="profile">
 | 
			
		||||
    <div class="profile__content" v-if="userLoggedIn">
 | 
			
		||||
      <section class='settings'>
 | 
			
		||||
        <h3 class='settings__header'>Plex account</h3>
 | 
			
		||||
 | 
			
		||||
        <div v-if="!hasPlexUser">
 | 
			
		||||
          <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>
 | 
			
		||||
          </form>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div v-else>
 | 
			
		||||
          <span class="settings__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 :messages.sync="messages" />
 | 
			
		||||
 | 
			
		||||
        <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 store from '@/store'
 | 
			
		||||
import storage from '@/storage'
 | 
			
		||||
import SeasonedInput from '@/components/ui/SeasonedInput'
 | 
			
		||||
import SeasonedButton from '@/components/ui/SeasonedButton'
 | 
			
		||||
import SeasonedMessages from '@/components/ui/SeasonedMessages'
 | 
			
		||||
 | 
			
		||||
import { getSettings, updateSettings, linkPlexAccount, unlinkPlexAccount } from '@/api'
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  components: { SeasonedInput, SeasonedButton, SeasonedMessages },
 | 
			
		||||
  data(){
 | 
			
		||||
    return{
 | 
			
		||||
      userLoggedIn: '',
 | 
			
		||||
      messages: [],
 | 
			
		||||
      plexUsername: null,
 | 
			
		||||
      plexPassword: null,
 | 
			
		||||
      newPassword: null,
 | 
			
		||||
      newPasswordRepeat: null,
 | 
			
		||||
      emoji: null
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    hasPlexUser: function() {
 | 
			
		||||
      return this.settings && this.settings['plex_userid']
 | 
			
		||||
    },
 | 
			
		||||
    settings: {
 | 
			
		||||
      get: () => {
 | 
			
		||||
        return store.getters['userModule/settings']
 | 
			
		||||
      },
 | 
			
		||||
      set: function(newSettings) {
 | 
			
		||||
        store.dispatch('userModule/setSettings', newSettings)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    setValue(l, t) {
 | 
			
		||||
      this[l] = t
 | 
			
		||||
    },
 | 
			
		||||
    changePassword() {
 | 
			
		||||
      return
 | 
			
		||||
    },
 | 
			
		||||
    async authenticatePlex() {
 | 
			
		||||
      let username = this.plexUsername
 | 
			
		||||
      let password = this.plexPassword
 | 
			
		||||
 | 
			
		||||
      const response = await linkPlexAccount(username, password)
 | 
			
		||||
 | 
			
		||||
      this.messages.push({
 | 
			
		||||
        type: response.success ? 'success' : 'error',
 | 
			
		||||
        title: response.success ? 'Authenticated with plex' : 'Something went wrong',
 | 
			
		||||
        message: response.message
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      if (response.success)
 | 
			
		||||
        getSettings().then(settings => this.settings = settings)
 | 
			
		||||
    },
 | 
			
		||||
    async unauthenticatePlex() {
 | 
			
		||||
      const response = await unlinkPlexAccount()
 | 
			
		||||
 | 
			
		||||
      this.messages.push({
 | 
			
		||||
        type: response.success ? 'success' : 'error',
 | 
			
		||||
        title: response.success ? 'Unlinked plex account ' : 'Something went wrong',
 | 
			
		||||
        message: response.message
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      if (response.success)
 | 
			
		||||
        getSettings().then(settings => this.settings = settings)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  created(){
 | 
			
		||||
    const token = localStorage.getItem('token') || false;
 | 
			
		||||
    if (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: 3rem;
 | 
			
		||||
 | 
			
		||||
  @include mobile-only {
 | 
			
		||||
    padding: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
   &__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 $text-color-50;
 | 
			
		||||
      margin-top: 30px;
 | 
			
		||||
      margin-bottom: 70px;
 | 
			
		||||
      margin-left: 20px;
 | 
			
		||||
      width: 96%;
 | 
			
		||||
      text-align: left;
 | 
			
		||||
   }
 | 
			
		||||
   span {
 | 
			
		||||
      font-weight: 200;
 | 
			
		||||
      size: 16px;
 | 
			
		||||
   }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										114
									
								
								src/components/Signin.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,114 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <section>
 | 
			
		||||
    <h1>Sign in</h1>
 | 
			
		||||
 | 
			
		||||
    <seasoned-input placeholder="username"
 | 
			
		||||
                    icon="Email"
 | 
			
		||||
                    type="email"
 | 
			
		||||
                    @enter="submit"
 | 
			
		||||
                    :value.sync="username" />
 | 
			
		||||
    <seasoned-input placeholder="password" icon="Keyhole" type="password" :value.sync="password" @enter="submit"/>
 | 
			
		||||
 | 
			
		||||
    <seasoned-button @click="submit">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 { login } from '@/api'
 | 
			
		||||
import storage from '../storage'
 | 
			
		||||
import SeasonedInput from '@/components/ui/SeasonedInput'
 | 
			
		||||
import SeasonedButton from '@/components/ui/SeasonedButton'
 | 
			
		||||
import SeasonedMessages from '@/components/ui/SeasonedMessages'
 | 
			
		||||
import { parseJwt } from '@/utils'
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  components: { SeasonedInput, SeasonedButton, SeasonedMessages },
 | 
			
		||||
  data(){
 | 
			
		||||
    return{
 | 
			
		||||
      messages: [],
 | 
			
		||||
      username: null,
 | 
			
		||||
      password: null
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    setValue(l, t) {
 | 
			
		||||
      this[l] = t
 | 
			
		||||
    },
 | 
			
		||||
    submit() {
 | 
			
		||||
      this.messages = [];
 | 
			
		||||
      let username = this.username;
 | 
			
		||||
      let password = this.password;
 | 
			
		||||
 | 
			
		||||
      if (username == null || username.length == 0) {
 | 
			
		||||
        this.messages.push({ type: 'error', title: 'Missing username' })
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (password == null || password.length == 0) {
 | 
			
		||||
        this.messages.push({ type: 'error', title: 'Missing password' })
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.signin(username, password)
 | 
			
		||||
    },
 | 
			
		||||
    signin(username, password) {
 | 
			
		||||
      login(username, password, true)
 | 
			
		||||
        .then(data => {
 | 
			
		||||
          if (data.success){
 | 
			
		||||
            const jwtData = parseJwt(data.token)
 | 
			
		||||
            localStorage.setItem('token', data.token);
 | 
			
		||||
            localStorage.setItem('username', jwtData['username']);
 | 
			
		||||
            localStorage.setItem('admin', jwtData['admin'] || false);
 | 
			
		||||
 | 
			
		||||
            eventHub.$emit('setUserStatus');
 | 
			
		||||
            this.$router.push({ name: 'profile' })
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
        .catch(error => {
 | 
			
		||||
          if (error.status === 401) {
 | 
			
		||||
            this.messages.push({ type: 'error', 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>
 | 
			
		||||
							
								
								
									
										519
									
								
								src/components/TorrentList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,519 @@
 | 
			
		||||
<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> -->
 | 
			
		||||
 | 
			
		||||
        <toggle-button :options="release_types" :selected.sync="selectedRelaseType" class="toggle"></toggle-button>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        <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'
 | 
			
		||||
import ToggleButton from '@/components/ui/ToggleButton'
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  components: { SeasonedButton, SeasonedInput, ToggleButton },
 | 
			
		||||
  props: {
 | 
			
		||||
    query: {
 | 
			
		||||
      type: String,
 | 
			
		||||
      require: true
 | 
			
		||||
    },
 | 
			
		||||
    tmdb_id: {
 | 
			
		||||
      type: Number,
 | 
			
		||||
      require: true
 | 
			
		||||
    },
 | 
			
		||||
    tmdb_type: String,
 | 
			
		||||
    admin: Boolean,
 | 
			
		||||
    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')
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    selectedRelaseType: function(newValue) {
 | 
			
		||||
      this.applyFilter(newValue)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  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]
 | 
			
		||||
 | 
			
		||||
      const clickedElement = event.target.parentNode;
 | 
			
		||||
      const scopedStyleDataVariable = Object.keys(clickedElement.dataset)[0]
 | 
			
		||||
 | 
			
		||||
      if (existingExpandedElement) {
 | 
			
		||||
        const expandedSibling = event.target.parentNode.nextSibling.className === 'expanded'
 | 
			
		||||
 | 
			
		||||
        existingExpandedElement.remove()
 | 
			
		||||
        const table = document.getElementsByTagName('table')[0]
 | 
			
		||||
        table.style.display = 'block'
 | 
			
		||||
 | 
			
		||||
        if (expandedSibling) {
 | 
			
		||||
          return
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const nameRow = document.createElement('tr')
 | 
			
		||||
      const nameCol = document.createElement('td')
 | 
			
		||||
      nameRow.className = 'expanded'
 | 
			
		||||
      nameRow.dataset[scopedStyleDataVariable] = "";
 | 
			
		||||
      nameCol.innerText = name
 | 
			
		||||
      nameCol.dataset[scopedStyleDataVariable] = "";
 | 
			
		||||
 | 
			
		||||
      nameRow.appendChild(nameCol)
 | 
			
		||||
 | 
			
		||||
      clickedElement.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
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      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;
 | 
			
		||||
  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 {
 | 
			
		||||
    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";
 | 
			
		||||
 | 
			
		||||
.toggle {
 | 
			
		||||
  max-width: unset !important;
 | 
			
		||||
  margin: 1rem 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.container {
 | 
			
		||||
  background-color: $background-color;
 | 
			
		||||
  padding: 0 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.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;
 | 
			
		||||
  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>
 | 
			
		||||
@@ -1,265 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,125 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,97 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,99 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,290 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,86 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,127 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,54 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,553 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,278 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,98 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,114 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,6 +0,0 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <code
 | 
			
		||||
    >Monitor active torrents requested. Requires authentication with owners plex
 | 
			
		||||
    library!</code
 | 
			
		||||
  >
 | 
			
		||||
</template>
 | 
			
		||||
@@ -1,162 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,269 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,88 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,48 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,85 +0,0 @@
 | 
			
		||||
<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,42 +1,22 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div :class="`loader type-${type || LoaderHeightType.Page}`">
 | 
			
		||||
  <div class="loader">
 | 
			
		||||
    <i class="loader--icon">
 | 
			
		||||
      <i class="loader--icon-spinner" />
 | 
			
		||||
    </i>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <!--
 | 
			
		||||
  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>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
  @import "src/scss/variables";
 | 
			
		||||
@import "./src/scss/variables";
 | 
			
		||||
 | 
			
		||||
  .loader {
 | 
			
		||||
.loader {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 30vh;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
 | 
			
		||||
    &.type-section {
 | 
			
		||||
      height: 15vh;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &--icon {
 | 
			
		||||
  &--icon{
 | 
			
		||||
    border: 2px solid $text-color-70;
 | 
			
		||||
    border-radius: 50%;
 | 
			
		||||
    display: block;
 | 
			
		||||
@@ -52,7 +32,7 @@
 | 
			
		||||
      &:after {
 | 
			
		||||
        border: 7px solid $green-90;
 | 
			
		||||
        border-radius: 50%;
 | 
			
		||||
          content: "";
 | 
			
		||||
        content: '';
 | 
			
		||||
        left: 8px;
 | 
			
		||||
        position: absolute;
 | 
			
		||||
        top: 22px;
 | 
			
		||||
@@ -60,9 +40,7 @@
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  @keyframes load {
 | 
			
		||||
      100% {
 | 
			
		||||
        transform: rotate(360deg);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    100% { transform: rotate(360deg); }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,26 +1,27 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <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>
 | 
			
		||||
    <div class="text-input__loading">
 | 
			
		||||
      <div class="text-input__loading--line" :class="lineClass" v-for="_ in Array(count)"></div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
  import { defineProps } from "vue";
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    count?: number;
 | 
			
		||||
    lineClass?: string;
 | 
			
		||||
    top?: number;
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    count: {
 | 
			
		||||
      type: Number,
 | 
			
		||||
      require: true
 | 
			
		||||
    },
 | 
			
		||||
    lineClass: {
 | 
			
		||||
      type: String,
 | 
			
		||||
      default: ''
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  defineProps<Props>();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
  @import "src/scss/loading-placeholder";
 | 
			
		||||
@import "./src/scss/loading-placeholder";
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
@@ -1,34 +1,33 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <button
 | 
			
		||||
    type="button"
 | 
			
		||||
    :class="{ active: active, fullwidth: fullWidth }"
 | 
			
		||||
    @click="emit('click')"
 | 
			
		||||
  >
 | 
			
		||||
  <button type="button" @click="emit('click')" :class="{ active: active }">
 | 
			
		||||
    <slot></slot>
 | 
			
		||||
  </button>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
  import { defineProps, defineEmits } from "vue";
 | 
			
		||||
<script>
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    active?: boolean;
 | 
			
		||||
    fullWidth?: boolean;
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'seasonedButton',
 | 
			
		||||
  props: {
 | 
			
		||||
    active: {
 | 
			
		||||
      type: Boolean,
 | 
			
		||||
      default: false,
 | 
			
		||||
      required: false
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  interface Emit {
 | 
			
		||||
    (e: "click");
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    emit() {
 | 
			
		||||
      this.$emit('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 {
 | 
			
		||||
button {
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
  border: 1px solid $text-color;
 | 
			
		||||
  font-size: 11px;
 | 
			
		||||
@@ -44,25 +43,14 @@
 | 
			
		||||
  background: $background-color-secondary;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  outline: none;
 | 
			
		||||
    transition: background 0.5s ease, color 0.5s ease, border-color 0.5s ease;
 | 
			
		||||
  transition: background 0.5s ease, color 0.5s ease, border-color .5s ease;
 | 
			
		||||
 | 
			
		||||
  @include desktop {
 | 
			
		||||
    font-size: 0.8rem;
 | 
			
		||||
    padding: 6px 20px 5px 20px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    &.fullwidth {
 | 
			
		||||
      font-size: 14px;
 | 
			
		||||
      width: 40%;
 | 
			
		||||
 | 
			
		||||
      @include mobile {
 | 
			
		||||
        width: 60%;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &:focus,
 | 
			
		||||
    &:active,
 | 
			
		||||
    &.active {
 | 
			
		||||
  &:focus, &:active, &.active {
 | 
			
		||||
    background: $text-color;
 | 
			
		||||
    color: $background-color;
 | 
			
		||||
  }
 | 
			
		||||
@@ -73,5 +61,5 @@
 | 
			
		||||
      color: $background-color;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,140 +1,116 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="group" :class="{ completed: modelValue, focus }">
 | 
			
		||||
    <component :is="inputIcon" v-if="inputIcon" />
 | 
			
		||||
  <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" />
 | 
			
		||||
    
 | 
			
		||||
    <!-- 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
 | 
			
		||||
    >
 | 
			
		||||
    <i v-if="value && type === 'password'" @click="toggleShowPassword" class="group__input-show noselect">show</i>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<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;
 | 
			
		||||
<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
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  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";
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    submit(event) {
 | 
			
		||||
      this.$emit('enter')
 | 
			
		||||
    },
 | 
			
		||||
    handleInput(event) {
 | 
			
		||||
      if (this.value !== undefined) {
 | 
			
		||||
        this.$emit('update:value', this.inputValue)
 | 
			
		||||
      } else {
 | 
			
		||||
      toggledType.value = "text";
 | 
			
		||||
        this.$emit('change', this.inputValue, event)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    toggleShowPassword() {
 | 
			
		||||
      if (this.tempType === 'text') {
 | 
			
		||||
        this.tempType = 'password'
 | 
			
		||||
      } else {
 | 
			
		||||
        this.tempType = '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 {
 | 
			
		||||
.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 {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    max-width: 35rem;
 | 
			
		||||
    border: 1px solid var(--text-color-50);
 | 
			
		||||
    background-color: var(--background-color-secondary);
 | 
			
		||||
 | 
			
		||||
    &.completed,
 | 
			
		||||
    &.focus,
 | 
			
		||||
    &:hover,
 | 
			
		||||
    &:focus {
 | 
			
		||||
      border-color: var(--text-color);
 | 
			
		||||
 | 
			
		||||
      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;
 | 
			
		||||
    padding: 10px 10px 10px 45px;
 | 
			
		||||
    outline: none;
 | 
			
		||||
      background-color: var(--background-color-secondary);
 | 
			
		||||
      color: var(--text-color);
 | 
			
		||||
    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;
 | 
			
		||||
      border: none;
 | 
			
		||||
    transition: color .5s ease, background-color .5s ease, border .5s ease;
 | 
			
		||||
 | 
			
		||||
    border-radius: 0;
 | 
			
		||||
    -webkit-appearance: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .show {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      display: grid;
 | 
			
		||||
      place-items: center;
 | 
			
		||||
      right: 20px;
 | 
			
		||||
    &-show {
 | 
			
		||||
      position: relative;
 | 
			
		||||
      left: -50px;
 | 
			
		||||
      z-index: 11;
 | 
			
		||||
      margin: auto 0;
 | 
			
		||||
      height: 100%;
 | 
			
		||||
      font-size: 0.9rem;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
      color: var(--text-color-50);
 | 
			
		||||
      -webkit-user-select: none;
 | 
			
		||||
      user-select: none;
 | 
			
		||||
      color: $text-color-50;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__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,120 +1,107 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <transition-group name="fade">
 | 
			
		||||
    <div
 | 
			
		||||
      v-for="(message, index) in messages"
 | 
			
		||||
      :key="generateMessageKey(index, message)"
 | 
			
		||||
      class="card"
 | 
			
		||||
      :class="message.type || 'warning'"
 | 
			
		||||
    >
 | 
			
		||||
    <div class="message" v-for="(message, index) in reversedMessages" :class="message.type || 'warning'" :key="index">
 | 
			
		||||
      <span class="pinstripe"></span>
 | 
			
		||||
      <div class="content">
 | 
			
		||||
        <h2 class="title">
 | 
			
		||||
          {{ message.title || titleFromType(message.type) }}
 | 
			
		||||
        </h2>
 | 
			
		||||
        <span v-if="message.message" class="message">{{
 | 
			
		||||
          message.message
 | 
			
		||||
        }}</span>
 | 
			
		||||
      <div>
 | 
			
		||||
        <h2 class="title">{{ message.title || defaultTitles[message.type] }}</h2>
 | 
			
		||||
        <span v-if="message.message" class="message">{{ message.message }}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <button class="dismiss" @click="dismiss(Number(index))">X</button>
 | 
			
		||||
      <button class="dismiss" @click="clicked(message)">X</button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </transition-group>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
  import { defineProps, defineEmits } from "vue";
 | 
			
		||||
  import type {
 | 
			
		||||
    ErrorMessageTypes,
 | 
			
		||||
    IErrorMessage
 | 
			
		||||
  } from "../../interfaces/IErrorMessage";
 | 
			
		||||
<script>
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    messages: IErrorMessage[];
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    messages: {
 | 
			
		||||
      required: true,
 | 
			
		||||
      type: Array
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  interface Emit {
 | 
			
		||||
    (e: "update:messages", messages: IErrorMessage[]);
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      defaultTitles: {
 | 
			
		||||
        error: 'Unexpected error',
 | 
			
		||||
        warning: 'Something went wrong',
 | 
			
		||||
        undefined: 'Something went wrong'
 | 
			
		||||
      },
 | 
			
		||||
      localMessages: [...this.messages]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  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];
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    reversedMessages() {
 | 
			
		||||
      return [...this.messages].reverse()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  function dismiss(index: number) {
 | 
			
		||||
    const _messages = [...props.messages];
 | 
			
		||||
    _messages.splice(index, 1);
 | 
			
		||||
    emit("update:messages", _messages);
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    clicked(e) {
 | 
			
		||||
      const removedMessage = [...this.messages].filter(mes => mes !== e)
 | 
			
		||||
      this.$emit('update:messages', removedMessage)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  function generateMessageKey(
 | 
			
		||||
    index: string | number | symbol,
 | 
			
		||||
    errorMessage: IErrorMessage
 | 
			
		||||
  ) {
 | 
			
		||||
    return `${String(index)}-${errorMessage.title}-${errorMessage.type}`;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
  @import "src/scss/variables";
 | 
			
		||||
  @import "src/scss/media-queries";
 | 
			
		||||
@import "./src/scss/variables";
 | 
			
		||||
@import "./src/scss/media-queries";
 | 
			
		||||
 | 
			
		||||
  .fade-active {
 | 
			
		||||
    transition: opacity 0.4s;
 | 
			
		||||
  }
 | 
			
		||||
  .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
 | 
			
		||||
.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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  .card {
 | 
			
		||||
.message {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  max-width: 35rem;
 | 
			
		||||
 | 
			
		||||
  display: flex;
 | 
			
		||||
    margin-top: 0.8rem;
 | 
			
		||||
  margin-top: 1rem;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
  color: $text-color-70;
 | 
			
		||||
 | 
			
		||||
    .content {
 | 
			
		||||
      margin: 0.4rem 1.2rem;
 | 
			
		||||
  > div {
 | 
			
		||||
    margin: 10px 24px;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .title {
 | 
			
		||||
    font-weight: 300;
 | 
			
		||||
    letter-spacing: 0.25px;
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    font-size: 1.3rem;
 | 
			
		||||
    color: $text-color;
 | 
			
		||||
        transition: color 0.5s ease;
 | 
			
		||||
    transition: color .5s ease;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .message {
 | 
			
		||||
        font-weight: 400;
 | 
			
		||||
        font-size: 1.2rem;
 | 
			
		||||
    font-weight: 300;
 | 
			
		||||
    color: $text-color-70;
 | 
			
		||||
        transition: color 0.5s ease;
 | 
			
		||||
        margin-bottom: 0.2rem;
 | 
			
		||||
    transition: color .5s ease;
 | 
			
		||||
    margin: 0.2rem 0 0.5rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @include mobile-only {
 | 
			
		||||
    > div {
 | 
			
		||||
      margin: 6px 6px;
 | 
			
		||||
      line-height: 1.3rem;
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
    h2 {
 | 
			
		||||
      font-size: 1.1rem;
 | 
			
		||||
    }
 | 
			
		||||
    span {
 | 
			
		||||
      font-size: 0.9rem;
 | 
			
		||||
    }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .pinstripe {
 | 
			
		||||
@@ -139,7 +126,7 @@
 | 
			
		||||
    margin-top: 0.5rem;
 | 
			
		||||
    margin-right: 0.5rem;
 | 
			
		||||
    color: $text-color-70;
 | 
			
		||||
      transition: color 0.5s ease;
 | 
			
		||||
    transition: color .5s ease;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      color: $text-color;
 | 
			
		||||
@@ -169,5 +156,6 @@
 | 
			
		||||
      background-color: $color-warning-highlight;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										9
									
								
								src/components/ui/SvgIcon.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,9 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div v-html="require(`@/assets/icons/${ icon }.svg`)"></div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
  export default {
 | 
			
		||||
    props: ['icon']
 | 
			
		||||
  }
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,52 +1,60 @@
 | 
			
		||||
<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>
 | 
			
		||||
    <button v-for="option in options" class="toggle-button" @click="toggle(option)"
 | 
			
		||||
      :class="toggleValue === option ? 'selected' : null"
 | 
			
		||||
    >{{ option }}</button>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
  import { defineProps, defineEmits } from "vue";
 | 
			
		||||
 | 
			
		||||
  interface Props {
 | 
			
		||||
    options: string[];
 | 
			
		||||
    selected?: string;
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    options: {
 | 
			
		||||
      Array,
 | 
			
		||||
      required: true
 | 
			
		||||
    },
 | 
			
		||||
    selected: {
 | 
			
		||||
      type: String,
 | 
			
		||||
      required: false,
 | 
			
		||||
      default: undefined
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  interface Emit {
 | 
			
		||||
    (e: "update:selected", selected: string);
 | 
			
		||||
    (e: "change");
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      toggleValue: this.selected || this.options[0]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  defineProps<Props>();
 | 
			
		||||
  const emit = defineEmits<Emit>();
 | 
			
		||||
 | 
			
		||||
  function toggleTo(option: string) {
 | 
			
		||||
    emit("update:selected", option);
 | 
			
		||||
    emit("change");
 | 
			
		||||
  },
 | 
			
		||||
  beforeMount() {
 | 
			
		||||
    this.toggle(this.toggleValue)
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    toggle(toggleValue) {
 | 
			
		||||
      this.toggleValue = toggleValue;
 | 
			
		||||
      if (this.selected !== undefined) {
 | 
			
		||||
        this.$emit('update:selected', toggleValue)
 | 
			
		||||
      } else {
 | 
			
		||||
        this.$emit('change', toggleValue)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
  @import "src/scss/variables";
 | 
			
		||||
@import "./src/scss/variables";
 | 
			
		||||
 | 
			
		||||
  $background: $background-ui;
 | 
			
		||||
  $background-selected: $background-color-secondary;
 | 
			
		||||
$background: $background-ui;
 | 
			
		||||
$background-selected: $background-color-secondary;
 | 
			
		||||
 | 
			
		||||
  .toggle-container {
 | 
			
		||||
.toggle-container {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  max-width: 15rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
    overflow-x: scroll;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  // padding: 0.2rem;
 | 
			
		||||
  background-color: $background;
 | 
			
		||||
  border: 2px solid $background;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
@@ -57,20 +65,36 @@
 | 
			
		||||
    font-size: 1rem;
 | 
			
		||||
    line-height: 1rem;
 | 
			
		||||
    font-weight: normal;
 | 
			
		||||
      padding: 0.5rem;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    padding: 0.5rem 0;
 | 
			
		||||
    border: 0;
 | 
			
		||||
    color: $text-color;
 | 
			
		||||
    // background-color: $text-color-5;
 | 
			
		||||
    background-color: $background;
 | 
			
		||||
    text-transform: capitalize;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
      display: block;
 | 
			
		||||
      flex: 1 0 auto;
 | 
			
		||||
 | 
			
		||||
    &.selected {
 | 
			
		||||
      color: $text-color;
 | 
			
		||||
      // background-color: $background-color-secondary;
 | 
			
		||||
      background-color: $background-selected;
 | 
			
		||||
      border-radius: 8px;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // &:first-of-type, &:last-of-type {
 | 
			
		||||
    //   border-left: 4px solid $background;
 | 
			
		||||
    //   border-right: 4px solid $background;
 | 
			
		||||
    // }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    // &:first-of-type {
 | 
			
		||||
    //   border-top-left-radius: 4px;
 | 
			
		||||
    //   border-bottom-left-radius: 4px;
 | 
			
		||||
    // }
 | 
			
		||||
 | 
			
		||||
    // &:last-of-type {
 | 
			
		||||
    //   border-top-right-radius: 4px;
 | 
			
		||||
    //   border-bottom-right-radius: 4px;
 | 
			
		||||
    // }
 | 
			
		||||
  }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										57
									
								
								src/components/ui/darkmodeToggle.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,57 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="darkToggle">
 | 
			
		||||
    <span @click="toggleDarkmode()">{{ darkmodeToggleIcon }}</span>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      darkmode: this.supported
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    toggleDarkmode() {
 | 
			
		||||
      this.darkmode = !this.darkmode;
 | 
			
		||||
      document.body.className = this.darkmode ? "dark" : "light";
 | 
			
		||||
    },
 | 
			
		||||
    supported() {
 | 
			
		||||
      const computedStyle = window.getComputedStyle(document.body);
 | 
			
		||||
      if (computedStyle["colorScheme"] != null)
 | 
			
		||||
        return computedStyle.colorScheme.includes("dark");
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    darkmodeToggleIcon() {
 | 
			
		||||
      return this.darkmode ? "🌝" : "🌚";
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
@import "./src/scss/media-queries";
 | 
			
		||||
.darkToggle {
 | 
			
		||||
  height: 25px;
 | 
			
		||||
  width: 25px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  // background-color: red;
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  margin-bottom: 10px;
 | 
			
		||||
  margin-right: 2px;
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  right: 0;
 | 
			
		||||
  z-index: 10;
 | 
			
		||||
 | 
			
		||||
  @include mobile-only {
 | 
			
		||||
    margin-bottom: 5rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  -webkit-user-select: none;
 | 
			
		||||
  -moz-user-select: none;
 | 
			
		||||
  -ms-user-select: none;
 | 
			
		||||
  user-select: none;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										138
									
								
								src/components/ui/sidebarListElem.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,138 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div>
 | 
			
		||||
    <a @click="$emit('click')">
 | 
			
		||||
      <li>
 | 
			
		||||
        <figure v-if="iconRef" :class="activeClassIfActive">
 | 
			
		||||
          <svg class="icon"><use :xlink:href="iconRefNameIfActive"/></svg>
 | 
			
		||||
        </figure>
 | 
			
		||||
 | 
			
		||||
        <span class="text" :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: false
 | 
			
		||||
    },
 | 
			
		||||
    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;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
 | 
			
		||||
    .icon {
 | 
			
		||||
      fill: $text-color;
 | 
			
		||||
      cursor: pointer;
 | 
			
		||||
        transform: scale(1.1, 1.1);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  .active {
 | 
			
		||||
    color: $text-color;
 | 
			
		||||
 | 
			
		||||
    .icon {
 | 
			
		||||
      fill: $green;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  .pending {
 | 
			
		||||
    color: #f8bd2d;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .text {
 | 
			
		||||
    margin-left: 26px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .supplementary-text {
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
    text-align: right;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  figure {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
 | 
			
		||||
    > svg {
 | 
			
		||||
      position: relative;
 | 
			
		||||
      top: 50%;
 | 
			
		||||
      width: 16px;
 | 
			
		||||
      height: 16px;
 | 
			
		||||
      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;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										5
									
								
								src/config.json.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
    "SEASONED_URL": "http://localhost:31459/api/",
 | 
			
		||||
    "ELASTIC_URL": "http://localhost:9200",
 | 
			
		||||
    "ELASTIC_INDEX": "shows,movies"
 | 
			
		||||
}
 | 
			
		||||
@@ -1,19 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,19 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,15 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,7 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,13 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,16 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,13 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||
@@ -1,7 +0,0 @@
 | 
			
		||||
<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>
 | 
			
		||||