Feat: Vue 3 typescripted #76

Merged
KevinMidboe merged 53 commits from feat/vue-3-typescripted into master 2022-08-15 18:39:12 +00:00
208 changed files with 13249 additions and 7800 deletions

View File

@@ -7,38 +7,101 @@ platform:
os: linux
arch: amd64
volumes:
- name: cache
host:
path: /tmp/cache
steps:
- name: frontend_install
image: node:13.6.0
commands:
- node -v
- yarn --version
- name: deploy
image: appleboy/drone-ssh
pull: true
secrets:
- ssh_key
when:
event:
- push
branch:
- master
- drone-test
status: success
settings:
host: 10.0.0.114
username: root
key:
from_secret: ssh_key
command_timeout: 600s
script:
- /home/kevin/deploy/seasoned.sh
- name: Load cached frontend packages
image: sinlead/drone-cache:1.0.0
settings:
action: load
key: yarn.lock
mount: node_modules
prefix: yarn-modules-seasoned
volumes:
- name: cache
path: /cache
- name: Frontend install
image: node:18.2.0
commands:
- node -v
- yarn --version
- yarn
- name: Cache frontend packages
image: sinlead/drone-cache:1.0.0
settings:
action: save
key: yarn.lock
mount: node_modules
prefix: yarn-modules-seasoned
volumes:
- name: cache
path: /cache
- name: Frontend build
image: node:18.2.0
commands:
- yarn build
environment:
ELASTIC:
from_secret: ELASTIC
ELASTIC_INDEX:
from_secret: ELASTIC_INDEX
SEASONED_API:
from_secret: SEASONED_API
SEASONED_DOMAIN:
from_secret: SEASONED_DOMAIN
- name: Lint project using eslint
image: node:18.2.0
commands:
- yarn lint
failure: ignore
- name: Build and publish docker image
image: plugins/docker
settings:
registry: ghcr.io
repo: ghcr.io/kevinmidboe/seasoned
dockerfile: Dockerfile
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_PASSWORD
tags: latest
when:
event:
- push
branch:
- master
- name: deploy
image: appleboy/drone-ssh
pull: true
secrets:
- ssh_key
when:
event:
- push
branch:
- master
- drone-test
status: success
settings:
host: 10.0.0.54
username: root
key:
from_secret: ssh_key
command_timeout: 600s
script:
- /home/kevin/deploy/seasoned.sh
trigger:
branch:
- master
event:
include:
- pull_request
- push
# - pull_request

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
SEASONED_API=
ELASTIC=
ELASTIC_INDEX=shows,movies
SEASONED_DOMAIN=

31
.eslintrc Normal file
View File

@@ -0,0 +1,31 @@
{
"root": true,
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"extends": [
"@vue/eslint-config-airbnb",
"plugin:vue/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
"rules": {
"vue/no-v-model-argument": "off",
"no-underscore-dangle": "off",
"vue/multi-word-component-names": "off",
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
},
"settings": {
"import/resolver": {
webpack: {
config: "./webpack.config.js"
}
}
}
}

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# config file - copy config.json.example
src/config.json
.env
# Build directory
dist/

View File

@@ -1,8 +0,0 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>

View File

@@ -5,6 +5,6 @@
"singleQuote": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"vueIndentScriptAndStyle": false,
"vueIndentScriptAndStyle": true,
"trailingComma": "none"
}

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM nginx:1.23.1
COPY public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf.template
COPY docker-entrypoint.sh /docker-entrypoint.d/05-docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.d/05-docker-entrypoint.sh
EXPOSE 5000
LABEL org.opencontainers.image.source https://github.com/kevinmidboe/seasoned

View File

@@ -1,45 +1,72 @@
# The Movie Database App
# Seasoned Request
A Vue.js project.
Seasoned request is frontend vue application for searching, requesting and viewing account watch activity.
![](https://github.com/dmtrbrl/tmdb-app/blob/master/docs/demo.gif)
## Demo
[TMDB Vue App](https://tmdb-vue-app.herokuapp.com/)
## Config setup
Set seasonedShows api endpoint and/or elastic.
- SeasonedShows [can be found here](https://github.com/kevinmidboe/seasonedshows) and is the matching backend to fetch tmdb search results, tmdb lists, request new content, check plex status and lets owner search and add torrents to download.
- Elastic is optional and can be used for a instant search feature for all movies and shows registered in tmdb.
```json
{
"SEASONED_URL": "http://localhost:31459/api",
"ELASTIC_URL": "http://localhost:9200"
}
```bash
# make copy of example environment file
cp .env.example .env
```
*Set ELASTIC_URL to undefined or false to disable*
## Build Setup
```bash
# .env sane default values
SEASONED_API=
ELASTIC=
ELASTIC_INDEX=shows,movies
SEASONED_DOMAIN=
```
``` bash
- Leave SEASONED_API empty to request `/api` from same origin and proxy passed by nginx, set if hosting [seasonedShows backend api](https://github.com/KevinMidboe/seasonedShows) locally.
- Elastic is optional and can be used for a instant search feature for all movies and shows registered in tmdb, leave empty to disable.
```bash
# .env example values
SEASONED_API=http://localhost:31459
ELASTIC=http://localhost:9200
ELASTIC_INDEX=shows,movies
SEASONED_DOMAIN=request.movie
```
## Build Steps
```bash
# install dependencies
npm install
yarn
# serve with hot reload at localhost:8080
npm run dev
# build vue project using webpack
yarn build
# build for production with minification
npm run build
# test or host built files using docker, might require sudo:
docker build -t seasoned .
docker run -d -p 5000:5000 --name seasoned-request --env-file .env seasoned
```
For detailed explanation on how things work, consult the [docs for vue-loader](http://vuejs.github.io/vue-loader).
This app uses [history mode](https://router.vuejs.org/en/essentials/history-mode.html)
## Development Steps
```bash
# serve project with hot reloading at localhost:8080
yarn dev
```
To proxy requests to `/api` either update `SEASONED_API` in `.env` or run set environment variable, e.g.:
```bash
# export and run
export SEASONED_API=http://localhost:31459
yarn dev
# or run with environment variable inline
SEASONED_API=http://localhost:31459 yarn dev
```
## Documentation
All api functions are documented in `/docs` and [found here](docs/api.md).
[html version also available](http://htmlpreview.github.io/?https://github.com/KevinMidboe/seasoned/blob/release/v2/docs/api/index.html)
## License
[MIT](https://github.com/dmtrbrl/tmdb-app/blob/master/LICENSE)

9
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
export SEASONED_API=${SEASONED_API:-http://localhost:31459}
export SEASONED_DOMAIN=${SEASONED_DOMAIN:-localhost}
envsubst '$SEASONED_API,$SEASONED_DOMAIN' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
exec "$@"

30
nginx.conf Normal file
View File

@@ -0,0 +1,30 @@
server {
listen 5000 default_server;
listen [::]:5000 default_server;
server_name $SEASONED_DOMAIN;
root /usr/share/nginx/html;
gzip on;
gzip_types application/javascript;
gzip_min_length 1000;
gzip_static on;
location /favicons {
autoindex on;
}
location /dist {
add_header Content-Type application/javascript;
try_files $uri =404;
}
location /api {
proxy_pass $SEASONED_API;
}
location / {
try_files $uri $uri/ /index.html;
index index.html;
}
}

View File

@@ -5,41 +5,57 @@
"author": "Kevin Midboe",
"private": true,
"scripts": {
"dev": "cross-env NODE_ENV=development webpack server",
"build": "cross-env NODE_ENV=production webpack-cli build --progress",
"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",
"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"
"clean": "rm -r public/dist 2> /dev/null; rm public/index.html 2> /dev/null; rm -r lib 2> /dev/null",
"start": "echo 'Start using docker, consult README'",
"lint": "eslint src --ext .ts,.vue",
"docs": "documentation build src/api.ts -f html -o docs/api && documentation build src/api.ts -f md -o docs/api.md"
},
"dependencies": {
"chart.js": "^2.9.2",
"connect-history-api-fallback": "1.6.0",
"cross-env": "6.0.0",
"express": "4.17.3",
"vue": "^3.2.37",
"vue-router": "4.1.2",
"vuex": "3.6.2"
"chart.js": "3.9.1",
"connect-history-api-fallback": "2.0.0",
"dotenv": "^16.0.1",
"express": "4.18.1",
"vue": "3.2.37",
"vue-router": "4.1.3",
"vuex": "4.0.2"
},
"devDependencies": {
"@babel/core": "7.17.2",
"@babel/plugin-transform-runtime": "7.17.0",
"@babel/preset-env": "7.16.11",
"@babel/runtime": "7.17.2",
"@types/node": "^18.6.1",
"babel-loader": "8.2.3",
"css-loader": "6.7.0",
"documentation": "^11.0.0",
"@babel/core": "7.18.10",
"@babel/plugin-transform-runtime": "7.18.10",
"@babel/preset-env": "7.18.10",
"@babel/runtime": "7.18.9",
"@types/express": "4.17.13",
"@types/node": "18.6.1",
"@typescript-eslint/eslint-plugin": "5.33.0",
"@typescript-eslint/parser": "5.33.0",
"@vue/cli": "5.0.8",
"@vue/cli-service": "5.0.8",
"@vue/eslint-config-airbnb": "6.0.0",
"babel-loader": "8.2.5",
"css-loader": "6.7.1",
"documentation": "13.2.5",
"eslint": "8.21.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-vue": "9.3.0",
"eslint-plugin-vuejs-accessibility": "1.2.0",
"file-loader": "6.2.0",
"html-webpack-plugin": "^5.5.0",
"sass": "1.49.9",
"sass-loader": "12.6.0",
"terser-webpack-plugin": "5.3.1",
"ts-loader": "^9.3.1",
"typescript": "^4.7.4",
"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.70.0",
"webpack-cli": "4.9.2",
"webpack-dev-server": "4.7.4"
"webpack": "5.74.0",
"webpack-cli": "4.10.0",
"webpack-dev-server": "4.9.3"
}
}

View File

@@ -3,7 +3,7 @@
height="900"
viewBox="0 0 600 900"
fill="none"
xmlns="http://www.w3.org/2000/svg"
version="1.1" xmlns="http://www.w3.org/2000/svg"
>
<rect width="600" height="900" fill="white" />
<path

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -3,7 +3,7 @@
height="900"
viewBox="0 0 600 900"
fill="none"
xmlns="http://www.w3.org/2000/svg"
version="1.1" xmlns="http://www.w3.org/2000/svg"
>
<rect width="600" height="900" fill="white" />
<path

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,18 +0,0 @@
const express = require("express");
const path = require("path");
const history = require("connect-history-api-fallback");
const publicPath = path.join(__dirname, "public");
app = express();
app.use("/", express.static(publicPath));
app.use(history({ index: "/" }));
app.get("/", function (req, res) {
res.sendFile(`${publicPath}/index.html`);
});
const port = process.env.PORT || 5001;
console.log("Server runnning at port:", port);
app.listen(port);

View File

@@ -1,14 +1,14 @@
<template>
<div id="app">
<!-- Header and hamburger navigation -->
<NavigationHeader class="header"></NavigationHeader>
<NavigationHeader class="header" />
<div class="navigation-icons-gutter desktop-only">
<NavigationIcons />
</div>
<!-- Display the component assigned to the given route (default: home) -->
<router-view class="content" :key="$route.fullPath"></router-view>
<router-view :key="router.currentRoute.value.path" class="content" />
<!-- Popup that will show above existing rendered content -->
<popup />
@@ -17,61 +17,54 @@
</div>
</template>
<script>
import NavigationHeader from "@/components/header/NavigationHeader";
import NavigationIcons from "@/components/header/NavigationIcons";
import Popup from "@/components/Popup";
import DarkmodeToggle from "@/components/ui/DarkmodeToggle";
<script setup lang="ts">
import { useRouter } from "vue-router";
import NavigationHeader from "@/components/header/NavigationHeader.vue";
import NavigationIcons from "@/components/header/NavigationIcons.vue";
import Popup from "@/components/Popup.vue";
import DarkmodeToggle from "@/components/ui/DarkmodeToggle.vue";
export default {
name: "app",
components: {
NavigationHeader,
NavigationIcons,
Popup,
DarkmodeToggle
}
};
const router = useRouter();
</script>
<style lang="scss">
@import "src/scss/main";
@import "src/scss/media-queries";
@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 {
#app {
display: grid;
grid-column: 2 / 3;
grid-row: 2;
z-index: 5;
grid-template-rows: var(--header-size);
grid-template-columns: var(--header-size) 1fr;
@include mobile {
grid-column: 1 / 3;
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>

View File

@@ -1,36 +1,24 @@
import config from "./config";
import { IList } from "./interfaces/IList";
import { IList, IMediaCredits, IPersonCredits } from "./interfaces/IList";
let { SEASONED_URL, ELASTIC_URL, ELASTIC_INDEX } = config;
if (!SEASONED_URL) {
SEASONED_URL = window.location.origin;
}
// TODO
// - Move autorization token and errors here?
const checkStatusAndReturnJson = response => {
if (!response.ok) {
throw response;
}
return response.json();
};
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
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getMovie = (
id,
checkExistance = false,
credits = false,
release_dates = false
{
checkExistance,
credits,
releaseDates
}: { checkExistance: boolean; credits: boolean; releaseDates?: boolean }
) => {
const url = new URL("/api/v2/movie", SEASONED_URL);
const url = new URL("/api/v2/movie", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
if (checkExistance) {
url.searchParams.append("check_existance", "true");
@@ -38,14 +26,14 @@ const getMovie = (
if (credits) {
url.searchParams.append("credits", "true");
}
if (release_dates) {
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}`);
console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console
throw error;
});
};
@@ -56,8 +44,15 @@ const getMovie = (
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getShow = (id, checkExistance = false, credits = false) => {
const url = new URL("/api/v2/show", SEASONED_URL);
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");
@@ -65,19 +60,18 @@ const getShow = (id, checkExistance = false, credits = false) => {
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}`);
console.error(`api error getting show: ${id}`); // eslint-disable-line no-console
throw error;
});
};
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Fetches tmdb person by id. Can optionally include cast credits in result object.
* @param {number} id
@@ -85,7 +79,7 @@ function delay(ms) {
* @returns {object} Tmdb response
*/
const getPerson = (id, credits = false) => {
const url = new URL("/api/v2/person", SEASONED_URL);
const url = new URL("/api/v2/person", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
if (credits) {
url.searchParams.append("credits", "true");
@@ -94,36 +88,24 @@ const getPerson = (id, credits = false) => {
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting person: ${id}`);
console.error(`api error getting person: ${id}`); // eslint-disable-line no-console
throw error;
});
};
const getCredits = (type, id) => {
if (type === "movie") {
return getMovieCredits(id);
} else if (type === "show") {
return getShowCredits(id);
} else if (type === "person") {
return getPersonCredits(id);
}
return [];
};
/**
* Fetches tmdb movie credits by id.
* @param {number} id
* @returns {object} Tmdb response
*/
const getMovieCredits = id => {
const url = new URL("/api/v2/movie", SEASONED_URL);
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}`);
console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console
throw error;
});
};
@@ -133,14 +115,14 @@ const getMovieCredits = id => {
* @param {number} id
* @returns {object} Tmdb response
*/
const getShowCredits = id => {
const url = new URL("/api/v2/show", SEASONED_URL);
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}`);
console.error(`api error getting show: ${id}`); // eslint-disable-line no-console
throw error;
});
};
@@ -150,14 +132,14 @@ const getShowCredits = id => {
* @param {number} id
* @returns {object} Tmdb response
*/
const getPersonCredits = id => {
const url = new URL("/api/v2/person", SEASONED_URL);
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}`);
console.error(`api error getting person: ${id}`); // eslint-disable-line no-console
throw error;
});
};
@@ -168,15 +150,12 @@ const getPersonCredits = id => {
* @param {number} [page=1]
* @returns {object} Tmdb list response
*/
const getTmdbMovieListByName = (
name: string,
page: number = 1
): Promise<IList> => {
const url = new URL("/api/v2/movie/" + name, SEASONED_URL);
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 })
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
};
/**
@@ -184,16 +163,16 @@ const getTmdbMovieListByName = (
* @param {number} [page=1]
* @returns {object} Request response
*/
const getRequests = (page: number = 1) => {
const url = new URL("/api/v2/request", SEASONED_URL);
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 })
// .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", SEASONED_URL);
const url = new URL("/api/v1/user/requests", API_HOSTNAME);
url.searchParams.append("page", page.toString());
return fetch(url.href).then(resp => resp.json());
@@ -206,7 +185,7 @@ const getUserRequests = (page = 1) => {
* @returns {object} Tmdb response
*/
const searchTmdb = (query, page = 1, adult = false, mediaType = null) => {
const url = new URL("/api/v2/search", SEASONED_URL);
const url = new URL("/api/v2/search", API_HOSTNAME);
if (mediaType != null && ["movie", "show", "person"].includes(mediaType)) {
url.pathname += `/${mediaType}`;
}
@@ -218,7 +197,7 @@ const searchTmdb = (query, page = 1, adult = false, mediaType = null) => {
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error searching: ${query}, page: ${page}`);
console.error(`api error searching: ${query}, page: ${page}`); // eslint-disable-line no-console
throw error;
});
};
@@ -232,13 +211,13 @@ const searchTmdb = (query, page = 1, adult = false, mediaType = null) => {
* @returns {object} Torrent response
*/
const searchTorrents = query => {
const url = new URL("/api/v1/pirate/search", SEASONED_URL);
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}`);
console.error(`api error searching torrents: ${query}`); // eslint-disable-line no-console
throw error;
});
};
@@ -247,26 +226,26 @@ const searchTorrents = query => {
* Add magnet to download queue.
* @param {string} magnet Magnet link
* @param {boolean} name Name of torrent
* @param {boolean} tmdb_id
* @param {boolean} tmdbId
* @returns {object} Success/Failure response
*/
const addMagnet = (magnet, name, tmdb_id) => {
const url = new URL("/api/v1/pirate/add", SEASONED_URL);
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: magnet,
name: name,
tmdb_id: tmdb_id
magnet,
name,
tmdb_id: tmdbId
})
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error adding magnet: ${name} ${error}`);
console.error(`api error adding magnet: ${name} ${error}`); // eslint-disable-line no-console
throw error;
});
};
@@ -281,7 +260,7 @@ const addMagnet = (magnet, name, tmdb_id) => {
* @returns {object} Success/Failure response
*/
const request = (id, type) => {
const url = new URL("/api/v2/request", SEASONED_URL);
const url = new URL("/api/v2/request", API_HOSTNAME);
const options = {
method: "POST",
@@ -292,7 +271,7 @@ const request = (id, type) => {
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error requesting: ${id}, type: ${type}`);
console.error(`api error requesting: ${id}, type: ${type}`); // eslint-disable-line no-console
throw error;
});
};
@@ -304,28 +283,22 @@ const request = (id, type) => {
* @returns {object} Success/Failure response
*/
const getRequestStatus = (id, type = undefined) => {
const url = new URL("/api/v2/request", SEASONED_URL);
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 => {
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}`
);
}
const { status } = resp;
if (status === 200) return true;
return false;
})
.catch(err => Promise.reject(err));
};
const watchLink = (title, year) => {
const url = new URL("/api/v1/plex/watch-link", SEASONED_URL);
const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME);
url.searchParams.append("title", title);
url.searchParams.append("year", year);
@@ -335,7 +308,7 @@ const watchLink = (title, year) => {
};
const movieImages = id => {
const url = new URL(`v2/movie/${id}/images`, SEASONED_URL);
const url = new URL(`v2/movie/${id}/images`, API_HOSTNAME);
return fetch(url.href).then(resp => resp.json());
};
@@ -343,7 +316,7 @@ const movieImages = id => {
// - - - Seasoned user endpoints - - -
const register = (username, password) => {
const url = new URL("/api/v1/user", SEASONED_URL);
const url = new URL("/api/v1/user", API_HOSTNAME);
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -353,17 +326,17 @@ const register = (username, password) => {
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(
"Unexpected error occured before receiving response. Error:",
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", SEASONED_URL);
const url = new URL("/api/v1/user/login", API_HOSTNAME);
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -371,38 +344,40 @@ const login = (username, password, throwError = false) => {
};
return fetch(url.href, options).then(resp => {
if (resp.status == 200) return resp.json();
if (resp.status === 200) return resp.json();
if (throwError) throw resp;
else console.error("Error occured when trying to sign in.\nError:", 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", SEASONED_URL);
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 (resp.status === 200) return resp.json();
if (throwError) throw resp;
else console.error("Error occured when trying to log out.\nError:", 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", SEASONED_URL);
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");
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", SEASONED_URL);
const url = new URL("/api/v1/user/settings", API_HOSTNAME);
const options = {
method: "PUT",
@@ -413,7 +388,7 @@ const updateSettings = settings => {
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log("api error updating user settings");
console.log("api error updating user settings"); // eslint-disable-line no-console
throw error;
});
};
@@ -421,7 +396,7 @@ const updateSettings = settings => {
// - - - Authenticate with plex - - -
const linkPlexAccount = (username, password) => {
const url = new URL("/api/v1/user/link_plex", SEASONED_URL);
const url = new URL("/api/v1/user/link_plex", API_HOSTNAME);
const body = { username, password };
const options = {
@@ -433,13 +408,13 @@ const linkPlexAccount = (username, password) => {
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error linking plex account: ${username}`);
console.error(`api error linking plex account: ${username}`); // eslint-disable-line no-console
throw error;
});
};
const unlinkPlexAccount = (username, password) => {
const url = new URL("/api/v1/user/unlink_plex", SEASONED_URL);
const unlinkPlexAccount = () => {
const url = new URL("/api/v1/user/unlink_plex", API_HOSTNAME);
const options = {
method: "POST",
@@ -449,21 +424,21 @@ const unlinkPlexAccount = (username, password) => {
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error unlinking plex account: ${username}`);
console.error(`api error unlinking your plex account`); // eslint-disable-line no-console
throw error;
});
};
// - - - User graphs - - -
const fetchChart = (urlPath, days, chartType) => {
const url = new URL("/api/v1/user" + urlPath, SEASONED_URL);
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);
console.log("DAMN WE FAILED!", resp); // eslint-disable-line no-console
throw Error(resp.statusText);
}
@@ -474,12 +449,12 @@ const fetchChart = (urlPath, days, chartType) => {
// - - - Random emoji - - -
const getEmoji = () => {
const url = new URL("/api/v1/emoji", SEASONED_URL);
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");
console.log("api error getting emoji"); // eslint-disable-line no-console
throw error;
});
};
@@ -495,7 +470,7 @@ const getEmoji = () => {
* @returns {object} List of movies and shows matching query
*/
const elasticSearchMoviesAndShows = (query, count = 22) => {
const url = new URL(`${ELASTIC_INDEX}/_search`, ELASTIC_URL);
const url = new URL(`${ELASTIC_INDEX}/_search`, ELASTIC);
const body = {
sort: [{ popularity: { order: "desc" } }, "_score"],
@@ -527,7 +502,7 @@ const elasticSearchMoviesAndShows = (query, count = 22) => {
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log(`api error searching elasticsearch: ${query}`);
console.log(`api error searching elasticsearch: ${query}`); // eslint-disable-line no-console
throw error;
});
};
@@ -539,7 +514,6 @@ export {
getMovieCredits,
getShowCredits,
getPersonCredits,
getCredits,
getTmdbMovieListByName,
searchTmdb,
getUserRequests,
@@ -557,7 +531,7 @@ export {
logout,
getSettings,
updateSettings,
fetchChart,
fetchGraphData,
getEmoji,
elasticSearchMoviesAndShows
};

View File

@@ -1,44 +1,51 @@
<template>
<div class="cast">
<ol class="persons">
<CastListItem v-for="person in cast" :person="person" :key="person.id" />
<CastListItem
v-for="credit in cast"
:key="credit.id"
:credit-item="credit"
/>
</ol>
</div>
</template>
<script>
import CastListItem from "src/components/CastListItem";
<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";
export default {
name: "CastList",
components: { CastListItem },
props: {
cast: {
type: Array,
required: true
}
interface Props {
cast: Array<IMovie | IShow | IPerson | ICast | ICrew>;
}
};
defineProps<Props>();
</script>
<style lang="scss">
.cast {
position: relative;
top: 0;
left: 0;
.cast {
position: relative;
top: 0;
left: 0;
ol {
overflow-x: scroll;
padding: 0;
list-style-type: none;
margin: 0;
display: flex;
ol {
overflow-x: scroll;
padding: 0;
list-style-type: none;
margin: 0;
display: flex;
scrollbar-width: none; /* for Firefox */
scrollbar-width: none; /* for Firefox */
&::-webkit-scrollbar {
display: none; /* for Chrome, Safari, and Opera */
&::-webkit-scrollbar {
display: none; /* for Chrome, Safari, and Opera */
}
}
}
}
</style>

View File

@@ -1,121 +1,117 @@
<template>
<li class="card">
<a @click="openCastItem">
<img class="persons--image" :src="pictureUrl" />
<p class="name">{{ person.name || person.title }}</p>
<p class="meta">{{ person.character || person.year }}</p>
<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>
import { mapActions } from "vuex";
<script setup lang="ts">
import { defineProps, computed } from "vue";
import { useStore } from "vuex";
import type { ICast, ICrew, IMovie, IShow } from "../interfaces/IList";
export default {
name: "CastListItem",
props: {
person: {
type: Object,
required: true
}
},
methods: {
...mapActions("popup", ["open"]),
openCastItem() {
let { id, type } = this.person;
if (type) {
this.open({ id, type });
}
}
},
computed: {
pictureUrl() {
const { profile_path, poster_path, poster } = this.person;
if (profile_path) return "https://image.tmdb.org/t/p/w185" + profile_path;
else if (poster_path)
return "https://image.tmdb.org/t/p/w185" + poster_path;
else if (poster) return "https://image.tmdb.org/t/p/w185" + poster;
return "/assets/no-image_small.svg";
}
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;
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;
li a p:first-of-type {
padding-top: 10px;
}
&: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;
li.card p {
font-size: 1em;
padding: 0 10px;
margin: 0;
overflow: hidden;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
// margin-top: auto;
max-height: calc((0.9em * var(--line-height)) * 1);
text-overflow: ellipsis;
max-height: calc(10px + ((16px * var(--line-height)) * 3));
}
a {
display: block;
text-decoration: none;
height: 100%;
display: flex;
flex-direction: column;
}
li.card {
margin: 10px;
margin-right: 4px;
padding-bottom: 10px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: auto;
max-height: 210px;
background-color: var(--background-color);
object-fit: cover;
min-width: 140px;
width: 140px;
background-color: var(--background-color-secondary);
color: var(--text-color);
transition: all 0.3s ease;
transform: scale(0.97) translateZ(0);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:first-of-type {
margin-left: 0;
}
&:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
transform: scale(1.03);
}
.name {
font-weight: 500;
}
.character {
font-size: 0.9em;
}
.meta {
font-size: 0.9em;
color: var(--text-color-70);
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
// margin-top: auto;
max-height: calc((0.9em * var(--line-height)) * 1);
}
a {
display: block;
text-decoration: none;
height: 100%;
display: flex;
flex-direction: column;
}
img {
width: 100%;
height: auto;
max-height: 210px;
background-color: var(--background-color);
object-fit: cover;
}
}
}
</style>

170
src/components/Graph.vue Normal file
View File

@@ -0,0 +1,170 @@
<template>
<canvas ref="graphCanvas"></canvas>
</template>
<script setup lang="ts">
import { ref, defineProps, onMounted, watch } from "vue";
import {
Chart,
LineElement,
BarElement,
PointElement,
LineController,
BarController,
LinearScale,
CategoryScale,
Legend,
Title,
Tooltip,
ChartType
} from "chart.js";
import type { Ref } from "vue";
import { convertSecondsToHumanReadable } from "../utils";
import { GraphValueTypes } from "../interfaces/IGraph";
import type { IGraphDataset, IGraphData } from "../interfaces/IGraph";
Chart.register(
LineElement,
BarElement,
PointElement,
LineController,
BarController,
LinearScale,
CategoryScale,
Legend,
Title,
Tooltip
);
interface Props {
name?: string;
data: IGraphData;
type: ChartType;
stacked: boolean;
datasetDescriptionSuffix: string;
tooltipDescriptionSuffix: string;
graphValueType?: GraphValueTypes;
}
Chart.defaults.elements.point.radius = 0;
Chart.defaults.elements.point.hitRadius = 10;
// Chart.defaults.elements.point.pointHoverRadius = 10;
Chart.defaults.elements.point.hoverBorderWidth = 4;
const props = defineProps<Props>();
const graphCanvas: Ref<HTMLCanvasElement> = ref(null);
let graphInstance = null;
/* eslint-disable no-use-before-define */
onMounted(() => generateGraph());
watch(() => props.data, generateGraph);
/* eslint-enable no-use-before-define */
const graphTemplates = [
{
backgroundColor: "rgba(54, 162, 235, 0.2)",
borderColor: "rgba(54, 162, 235, 1)",
borderWidth: 1,
tension: 0.4
},
{
backgroundColor: "rgba(255, 159, 64, 0.2)",
borderColor: "rgba(255, 159, 64, 1)",
borderWidth: 1,
tension: 0.4
},
{
backgroundColor: "rgba(255, 99, 132, 0.2)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
tension: 0.4
}
];
// const gridColor = getComputedStyle(document.documentElement).getPropertyValue(
// "--text-color-5"
// );
function hydrateGraphLineOptions(dataset: IGraphDataset, index: number) {
return {
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
data: dataset.data,
...graphTemplates[index]
};
}
function removeEmptyDataset(dataset: IGraphDataset) {
/* eslint-disable-next-line no-unneeded-ternary */
return dataset.data.every(point => point === 0) ? false : true;
}
function generateGraph() {
const datasets = props.data.series
.filter(removeEmptyDataset)
.map(hydrateGraphLineOptions);
const graphOptions = {
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
// title: (tooltipItem, data) => `Watch date: ${tooltipItem[0].label}`,
label: tooltipItem => {
const context = tooltipItem.dataset.label.split(" ")[0];
const text = `${context} ${props.tooltipDescriptionSuffix}`;
let value = tooltipItem.raw;
if (props.graphValueType === GraphValueTypes.Time) {
value = convertSecondsToHumanReadable(value);
}
return ` ${text}: ${value}`;
}
}
}
},
scales: {
xAxes: {
stacked: props.stacked,
gridLines: {
display: false
}
},
yAxes: {
stacked: props.stacked,
ticks: {
callback: value => {
if (props.graphValueType === GraphValueTypes.Time) {
return convertSecondsToHumanReadable(value);
}
return value;
},
beginAtZero: true
}
}
}
};
const chartData = {
labels: props.data.labels.toString().split(","),
datasets
};
if (graphInstance) {
graphInstance.clear();
graphInstance.data = chartData;
graphInstance.update("none");
return;
}
graphInstance = new Chart(graphCanvas.value, {
type: props.type,
data: chartData,
options: graphOptions
});
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,8 +1,6 @@
<template>
<header
:class="{ expanded, noselect: true }"
v-bind:style="{ 'background-image': 'url(' + imageFile + ')' }"
>
<header ref="headerElement" :class="{ expanded, noselect: true }">
<img ref="imageElement" :src="bannerImage" alt="Page banner image" />
<div class="container">
<h1 class="title">Request movies or tv shows</h1>
<strong class="subtitle"
@@ -10,150 +8,211 @@
>
</div>
<div class="expand-icon" @click="expanded = !expanded">
<div
class="expand-icon"
@click="expand"
@keydown.enter="expand"
@mouseover="upgradeImage"
@focus="focus"
>
<IconExpand v-if="!expanded" />
<IconShrink v-else />
</div>
</header>
</template>
<script>
import IconExpand from "../icons/IconExpand.vue";
import IconShrink from "../icons/IconShrink.vue";
<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";
export default {
components: { IconExpand, IconShrink },
props: {
image: {
type: String,
required: false
}
},
data() {
return {
images: [
"pulp-fiction.jpg",
"arrival.jpg",
"dune.jpg",
"mandalorian.jpg"
],
imageFile: undefined,
expanded: false
};
},
beforeMount() {
if (this.image && this.image.length > 0) {
this.imageFile = this.image;
} else {
this.imageFile = `/assets/${
this.images[Math.floor(Math.random() * this.images.length)]
}`;
const ASSET_URL = "https://request.movie/assets/";
const images: Array<string> = [
"pulp-fiction.jpg",
"arrival.jpg",
"disaster-artist.jpg",
"dune.jpg",
"mandalorian.jpg"
];
const bannerImage: Ref<string> = ref();
const expanded: Ref<boolean> = ref(false);
const headerElement: Ref<HTMLElement> = ref(null);
const imageElement: Ref<HTMLImageElement> = ref(null);
const defaultHeaderHeight: Ref<string> = ref();
// const disableProxy = true;
function expand() {
expanded.value = !expanded.value;
let height = defaultHeaderHeight?.value;
if (expanded.value) {
const aspectRation =
imageElement.value.naturalHeight / imageElement.value.naturalWidth;
height = `${imageElement.value.clientWidth * aspectRation}px`;
defaultHeaderHeight.value = headerElement.value.style.height;
}
headerElement.value.style.setProperty("--header-height", height);
}
};
function focus(event: FocusEvent) {
event.preventDefault();
}
function randomImage(): string {
const image = images[Math.floor(Math.random() * images.length)];
return ASSET_URL + image;
}
bannerImage.value = randomImage();
// function sliceToHeaderSize(url: string): string {
// let width = headerElement.value?.getBoundingClientRect()?.width || 1349;
// let height = headerElement.value?.getBoundingClientRect()?.height || 261;
// if (disableProxy) return url;
// return buildProxyURL(width, height, url);
// }
// function upgradeImage() {
// if (disableProxy || imageUpgraded.value == true) return;
// const headerSize = 90;
// const height = window.innerHeight - headerSize;
// const width = window.innerWidth - headerSize;
// const proxyHost = `http://imgproxy.schleppe:8080/insecure/`;
// const proxySizeOptions = `q:65/plain/`;
// bannerImage.value = `${proxyHost}${proxySizeOptions}${
// ASSET_URL + image.value
// }`;
// }
// function buildProxyURL(width: number, height: number, asset: string): string {
// const proxyHost = `http://imgproxy.schleppe:8080/insecure/`;
// const proxySizeOptions = `resize:fill:${width}:${height}:ce/q:65/plain/`;
// return `${proxyHost}${proxySizeOptions}${asset}`;
// }
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/variables";
@import "src/scss/media-queries";
header {
width: 100%;
height: 25vh;
display: flex;
align-items: center;
justify-content: center;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
position: relative;
header {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: height 0.5s ease;
overflow: hidden;
--header-height: 25vh;
&.expanded {
height: calc(100vh - var(--header-size));
width: calc(100vw - var(--header-size));
height: var(--header-height);
@include mobile {
width: 100vw;
height: 100vh;
> * {
z-index: 1;
}
img {
position: absolute;
z-index: 0;
object-fit: cover;
width: 100%;
}
&.expanded {
// height: calc(100vh - var(--header-size));
// width: calc(100vw - var(--header-size));
// @include mobile {
// width: 100vw;
// height: 100vh;
// }
&:before {
background-color: transparent;
}
.title,
.subtitle {
opacity: 0;
}
}
.expand-icon {
visibility: hidden;
opacity: 0;
transition: all 0.5s ease-in-out;
height: 1.8rem;
width: 1.8rem;
fill: var(--text-color-50);
position: absolute;
top: 0.5rem;
right: 1rem;
&:hover {
cursor: pointer;
fill: var(--text-color-90);
}
}
&:hover {
.expand-icon {
visibility: visible;
opacity: 1;
}
}
&:before {
background-color: transparent;
content: "";
z-index: 1;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background-70);
transition: inherit;
}
.title,
.subtitle {
opacity: 0;
.container {
text-align: center;
position: relative;
transition: color 0.5s ease;
}
}
.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;
.title {
font-weight: 500;
font-size: 22px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: $text-color;
margin: 0;
opacity: 1;
@include tablet-min {
font-size: 2.5rem;
}
}
.subtitle {
display: block;
font-size: 14px;
font-weight: 300;
color: $text-color-70;
margin: 5px 0;
opacity: 1;
@include tablet-min {
font-size: 1.3rem;
}
}
}
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--background-70);
transition: inherit;
}
.container {
text-align: center;
position: relative;
transition: color 0.5s ease;
}
.title {
font-weight: 500;
font-size: 22px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: $text-color;
margin: 0;
opacity: 1;
@include tablet-min {
font-size: 2.5rem;
}
}
.subtitle {
display: block;
font-size: 14px;
font-weight: 300;
color: $text-color-70;
margin: 5px 0;
opacity: 1;
@include tablet-min {
font-size: 1.3rem;
}
}
}
</style>

View File

@@ -1,125 +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>
export default {
props: {
title: {
type: String,
required: true
},
subtitle: {
type: String,
required: false,
default: null
},
info: {
type: [String, Array],
required: false
},
link: {
type: String,
required: false
},
shortList: {
type: Boolean,
required: false,
default: false
}
},
computed: {
urlify: function () {
return `/list/${this.title.toLowerCase().replace(" ", "_")}`;
},
prettify: function () {
return this.title.includes("_")
? this.title.split("_").join(" ")
: this.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>

View File

@@ -0,0 +1,110 @@
<template>
<header>
<h2>{{ prettify }}</h2>
<h3>{{ subtitle }}</h3>
<router-link
v-if="shortList"
:to="urlify"
class="view-more"
:aria-label="`View all ${title}`"
>
View All
</router-link>
<div v-else-if="info">
<div v-if="info instanceof Array" class="flex flex-direction-column">
<span v-for="item in info" :key="item" class="info">{{ item }}</span>
</div>
<span v-else class="info">{{ info }}</span>
</div>
</header>
</template>
<script setup lang="ts">
import { defineProps, computed } from "vue";
interface Props {
title: string;
subtitle?: string;
info?: string | Array<string>;
link?: string;
shortList?: boolean;
}
const props = defineProps<Props>();
const urlify = computed(() => {
return `/list/${props.title.toLowerCase().replace(" ", "_")}`;
});
const prettify = computed(() => {
return props.title.includes("_")
? props.title.split("_").join(" ")
: props.title;
});
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background-color: $background-color;
position: sticky;
position: -webkit-sticky;
top: $header-size;
z-index: 1;
h2 {
font-size: 1.4rem;
font-weight: 300;
text-transform: capitalize;
line-height: 1.4rem;
margin: 0;
color: $text-color;
}
.view-more {
font-size: 0.9rem;
font-weight: 300;
letter-spacing: 0.5px;
color: $text-color-70;
text-decoration: none;
transition: color 0.5s ease;
cursor: pointer;
&:after {
content: " →";
}
&:hover {
color: $text-color;
}
}
.info {
font-size: 13px;
font-weight: 300;
letter-spacing: 0.5px;
color: $text-color;
text-decoration: none;
text-align: right;
}
@include tablet-min {
padding-left: 1.25rem;
}
@include desktop-lg-min {
padding-left: 1.75rem;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="isOpen" class="movie-popup" @click="close">
<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>
@@ -9,117 +9,144 @@
</div>
</template>
<script>
import { mapActions, mapGetters } from "vuex";
import Movie from "@/components/popup/Movie";
import Person from "@/components/popup/Person";
<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";
export default {
components: { Movie, Person },
computed: {
...mapGetters("popup", ["isOpen", "id", "type"])
},
watch: {
isOpen(value) {
value
? document.getElementsByTagName("body")[0].classList.add("no-scroll")
: document
.getElementsByTagName("body")[0]
.classList.remove("no-scroll");
}
},
methods: {
...mapActions("popup", ["close", "open"]),
checkEventForEscapeKey(event) {
if (event.keyCode == 27) this.close();
}
},
created() {
const params = new URLSearchParams(window.location.search);
let id = null;
let type = null;
if (params.has("movie")) {
id = Number(params.get("movie"));
type = "movie";
} else if (params.has("show")) {
id = Number(params.get("show"));
type = "show";
} else if (params.has("person")) {
id = Number(params.get("person"));
type = "person";
}
if (id && type) {
this.open({ id, type });
}
window.addEventListener("keyup", this.checkEventForEscapeKey);
},
beforeDestroy() {
window.removeEventListener("keyup", this.checkEventForEscapeKey);
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";
@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;
.movie-popup {
position: fixed;
top: 0;
right: 0;
border: 0;
background: transparent;
width: 40px;
height: 40px;
transition: background 0.5s ease;
cursor: pointer;
z-index: 5;
left: 0;
z-index: 20;
width: 100%;
height: 100%;
background: rgba($dark, 0.93);
-webkit-overflow-scrolling: touch;
overflow: auto;
&:before,
&:after {
content: "";
&__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: 19px;
left: 10px;
width: 20px;
height: 2px;
background: $white;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
&:hover {
background: $green;
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>

View File

@@ -6,9 +6,9 @@
:class="{ shortList: shortList }"
>
<results-list-item
v-for="(movie, index) in results"
:key="`${movie.type}-${movie.id}-${index}`"
:movie="movie"
v-for="(result, index) in results"
:key="generateResultKey(index, `${result.type}-${result.id}`)"
:list-item="result"
/>
</ul>
@@ -16,68 +16,62 @@
</div>
</template>
<script>
import ResultsListItem from "@/components/ResultsListItem";
<script setup lang="ts">
import { defineProps } from "vue";
import ResultsListItem from "@/components/ResultsListItem.vue";
import type { ListResults } from "../interfaces/IList";
export default {
components: { ResultsListItem },
props: {
results: {
type: Array,
required: true
},
shortList: {
type: Boolean,
required: false,
default: false
},
loading: {
type: Boolean,
required: false,
default: false
}
interface Props {
results: Array<ListResults>;
shortList?: boolean;
loading?: boolean;
}
defineProps<Props>();
function generateResultKey(index: string | number | symbol, value: string) {
return `${String(index)}-${value}`;
}
};
</script>
<style lang="scss">
@import "src/scss/media-queries";
@import "src/scss/main";
<style lang="scss" scoped>
@import "src/scss/media-queries";
@import "src/scss/main";
.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;
margin: 0;
padding: 0;
list-style: none;
@include mobile {
grid-template-columns: repeat(2, 1fr);
.no-results {
width: 100%;
display: block;
text-align: center;
margin: 1.5rem;
font-size: 1.2rem;
}
&.shortList {
overflow: auto;
grid-auto-flow: column;
max-width: 100vw;
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
grid-auto-rows: auto;
margin: 0;
padding: 0;
list-style: none;
@include noscrollbar;
> li {
min-width: 225px;
@include mobile {
grid-template-columns: repeat(2, 1fr);
}
@include tablet-min {
max-width: calc(100vw - var(--header-size));
&.shortList {
overflow: auto;
grid-auto-flow: column;
max-width: 100vw;
@include noscrollbar;
> li {
min-width: 225px;
}
@include tablet-min {
max-width: calc(100vw - var(--header-size));
}
}
}
}
</style>

View File

@@ -1,6 +1,11 @@
<template>
<li class="movie-item" ref="list-item">
<figure ref="poster" class="movie-item__poster" @click="openMoviePopup">
<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"
@@ -8,173 +13,171 @@
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 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="movie.title || movie.name" class="movie-item__title">
{{ movie.title || movie.name }}
<p v-if="listItem.title || listItem.name" class="movie-item__title">
{{ listItem.title || listItem.name }}
</p>
<p v-if="movie.year">{{ movie.year }}</p>
<p v-if="movie.type == 'person'">
Known for: {{ movie.known_for_department }}
<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>
import { mapActions } from "vuex";
import img from "../directives/v-image";
import { buildImageProxyUrl } from "../utils";
<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";
export default {
props: {
movie: {
type: Object,
required: true
}
},
directives: {
img: img
},
data() {
return {
poster: null,
observed: false
};
},
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}`;
},
imageWidth() {
if (this.image)
return Math.ceil(this.image.getBoundingClientRect().width);
},
imageHeight() {
if (this.image)
return Math.ceil(this.image.getBoundingClientRect().height);
}
},
beforeMount() {
if (this.movie.poster == null) {
this.poster = "/assets/no-image.svg";
return;
}
interface Props {
listItem: IMovie | IShow | IPerson;
}
this.poster = `https://image.tmdb.org/t/p/w500${this.movie.poster}`;
// this.poster = this.buildProxyURL(
// this.imageWidth,
// this.imageHeight,
// assetUrl
// );
},
mounted() {
const poster = this.$refs["poster"];
this.image = poster.getElementsByTagName("img")[0];
if (this.image == null) return;
const props = defineProps<Props>();
const store = useStore();
const imageObserver = new IntersectionObserver((entries, imgObserver) => {
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 && this.observed == false) {
const lazyImage = entry.target;
if (entry.isIntersecting && observed.value === false) {
const lazyImage = entry.target as HTMLImageElement;
lazyImage.src = lazyImage.dataset.src;
poster.className = poster.className + " is-loaded";
this.observed = true;
posterElement.value.classList.add("is-loaded");
observed.value = true;
}
});
});
imageObserver.observe(this.image);
},
methods: {
...mapActions("popup", ["open"]),
openMoviePopup() {
this.open({
id: this.movie.id,
type: this.movie.type
});
}
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";
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
.movie-item {
padding: 15px;
width: 100%;
background-color: var(--background-color);
.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 &__info > p {
color: $text-color;
}
&: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 {
&__poster {
text-decoration: none;
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;
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;
}
@include tablet-min {
font-size: 14px;
&: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;
}
}
}
&__title {
font-weight: 400;
&__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>

View File

@@ -1,12 +1,12 @@
<template>
<div ref="resultSection" class="resultSection">
<list-header v-bind="{ title, info, shortList }" />
<page-header v-bind="{ title, info, shortList }" />
<div
v-if="!loadedPages.includes(1) && loading == false"
class="button-container"
>
<seasoned-button @click="loadLess" class="load-button" :fullWidth="true"
<seasoned-button class="load-button" :full-width="true" @click="loadLess"
>load previous</seasoned-button
>
</div>
@@ -16,187 +16,188 @@
<div ref="loadMoreButton" class="button-container">
<seasoned-button
class="load-button"
v-if="!loading && !shortList && page != totalPages && results.length"
class="load-button"
:full-width="true"
@click="loadMore"
:fullWidth="true"
>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 store from "@/store";
import { getTmdbMovieListByName } from "@/api";
import Loader from "@/components/ui/Loader";
<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";
export default {
props: {
apiFunction: {
type: Function,
required: true
},
title: {
type: String,
required: true
},
shortList: {
type: Boolean,
required: false,
default: false
}
},
components: { ListHeader, ResultsList, SeasonedButton, Loader },
data() {
return {
results: [],
page: 1,
loadedPages: [],
totalPages: -1,
totalResults: 0,
loading: true,
autoLoad: false,
observer: undefined
};
},
computed: {
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() {
if (!this.autoLoad) {
this.autoLoad = true;
}
this.loading = true;
let maxPage = [...this.loadedPages].slice(-1)[0];
if (maxPage == NaN) return;
this.page = maxPage + 1;
this.getListResults();
},
loadLess() {
this.loading = true;
const minPage = this.loadedPages[0];
if (minPage === 1) return;
this.page = minPage - 1;
this.getListResults(true);
},
updateQueryParams() {
let params = new URLSearchParams(window.location.search);
if (params.has("page")) {
params.set("page", this.page);
} else if (this.page > 1) {
params.append("page", this.page);
}
window.history.replaceState(
{},
"search",
`${window.location.protocol}//${window.location.hostname}${
window.location.port ? `:${window.location.port}` : ""
}${window.location.pathname}${
params.toString().length ? `?${params}` : ""
}`
);
},
getPageFromUrl() {
return new URLSearchParams(window.location.search).get("page");
},
getListResults(front = false) {
this.apiFunction(this.page)
.then(results => {
if (!front) this.results = this.results.concat(...results.results);
else this.results = results.results.concat(...this.results);
this.page = results.page;
this.loadedPages.push(this.page);
this.loadedPages = this.loadedPages.sort((a, b) => a - b);
this.totalPages = results.total_pages;
this.totalResults = results.total_results;
})
.then(this.updateQueryParams)
.finally(() => (this.loading = false));
},
setupAutoloadObserver() {
this.observer = new IntersectionObserver(this.handleButtonIntersection, {
root: this.$refs.resultSection.$el,
rootMargin: "0px",
threshold: 0
});
this.observer.observe(this.$refs.loadMoreButton);
},
handleButtonIntersection(entries) {
entries.map(entry =>
entry.isIntersecting && this.autoLoad ? this.loadMore() : null
);
}
},
created() {
this.page = this.getPageFromUrl() || this.page;
if (this.results.length === 0) this.getListResults();
if (!this.shortList) {
store.dispatch(
"documentTitle/updateTitle",
`${this.$router.history.current.name} ${this.title}`
);
}
},
mounted() {
if (!this.shortList) {
this.setupAutoloadObserver();
}
},
beforeDestroy() {
this.observer = undefined;
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";
@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;
.resultSection {
background-color: var(--background-color);
}
&:last-of-type {
margin-bottom: 4rem;
.button-container {
display: flex;
justify-content: center;
display: flex;
width: 100%;
}
.load-button {
margin: 2rem 0;
@include mobile {
margin-bottom: 2rem;
margin: 1rem 0;
}
&:last-of-type {
margin-bottom: 4rem;
@include mobile {
margin-bottom: 2rem;
}
}
}
}
</style>

View File

@@ -1,747 +0,0 @@
<template>
<div v-if="show" class="container">
<h2 class="torrentHeader-text editable">
Searching for:
<span :contenteditable="!edit" @input="this.handleInput">{{
query
}}</span>
<IconSearch
class="icon"
v-if="editedSearchQuery && editedSearchQuery.length"
/>
<IconEdit v-else class="icon" @click="() => (this.edit = !this.edit)" />
</h2>
<div v-if="!loading">
<div v-if="torrents.length > 0">
<table>
<thead class="table__header noselect">
<tr>
<th
v-for="column in columns"
:key="column"
@click="sortTable(column)"
:class="column === selectedColumn ? 'active' : null"
>
{{ column }}
<span v-if="prevCol === column && direction"></span>
<span v-if="prevCol === column && !direction"></span>
</th>
</tr>
<!-- <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>
<th>
<span>Magnet</span>
</th> -->
</thead>
<tbody>
<tr
v-for="torrent in torrents"
class="table__content"
:key="torrent.magnet"
>
<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"
>
<IconMagnet />
</td>
</tr>
</tbody>
</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"
: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 store from "@/store";
import { sortableSize } from "@/utils";
import { searchTorrents, addMagnet } from "@/api";
import IconMagnet from "../icons/IconMagnet";
import IconEdit from "../icons/IconEdit";
import IconSearch from "../icons/IconSearch";
import SeasonedButton from "@/components/ui/SeasonedButton";
import SeasonedInput from "@/components/ui/SeasonedInput";
import ToggleButton from "@/components/ui/ToggleButton";
export default {
components: {
IconMagnet,
IconEdit,
IconSearch,
SeasonedButton,
SeasonedInput,
ToggleButton
},
props: {
query: {
type: String,
require: true
},
tmdb_id: {
type: Number,
require: true
},
tmdb_type: String,
admin: Boolean,
show: Boolean
},
data() {
return {
edit: true,
loading: false,
torrents: [],
torrentResponse: undefined,
currentPage: 0,
prevCol: "",
direction: false,
release_types: ["all"],
selectedRelaseType: "all",
editSearchQuery: false,
editedSearchQuery: "",
columns: ["name", "seed", "size", "magnet"],
selectedColumn: null
};
},
created() {
this.fetchTorrents().then(_ => this.sortTable("size"));
},
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;
}
if (col === "name") this.sortName();
else if (col === "seed") this.sortSeed();
else if (col === "size") this.sortSize();
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
);
},
filterDeadTorrents(torrents) {
return torrents.filter(torrent => {
if (isNaN(torrent.seed)) return false;
return parseInt(torrent.seed) > 0;
});
},
fetchTorrents(query = undefined) {
this.loading = true;
this.editSearchQuery = false;
return searchTorrents(query || this.query)
.then(data => {
const { results } = data;
if (results) {
this.torrentResponse = results;
this.torrents = this.filterDeadTorrents(results);
} else {
this.torrents = [];
}
})
.then(this.updateResultCountInStore)
.then(this.findRelaseTypes)
.catch(e => {
console.log("e:", e);
const error = e.toString();
this.errorMessage =
error.indexOf("401") != -1 ? "Permission denied" : "Nothing found";
})
.finally(() => {
this.loading = false;
});
},
handleInput(event) {
this.editedSearchQuery = event.target.innerText;
console.log("edit text:", this.editedSearchQuery);
}
}
};
</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%;
}
}
$checkboxSize: 20px;
$ui-border-width: 2px;
.checkbox {
display: flex;
flex-direction: row;
margin-bottom: $checkboxSize * 0.5;
input[type="checkbox"] {
display: block;
opacity: 0;
position: absolute;
+ div {
position: relative;
display: inline-block;
padding-left: 1.25rem;
font-size: 20px;
line-height: $checkboxSize + $ui-border-width * 2;
left: $checkboxSize;
cursor: pointer;
&::before {
content: "";
display: inline-block;
position: absolute;
left: -$checkboxSize;
border: $ui-border-width solid var(--color-green);
width: $checkboxSize;
height: $checkboxSize;
}
&::after {
transition: all 0.3s ease;
content: "";
position: absolute;
display: inline-block;
left: -$checkboxSize + $ui-border-width;
top: $ui-border-width;
width: $checkboxSize + $ui-border-width;
height: $checkboxSize + $ui-border-width;
}
}
&:checked {
+ div::after {
background-color: var(--color-green);
opacity: 1;
}
}
&:hover:not(checked) {
+ div::after {
background-color: var(--color-green);
opacity: 0.4;
}
}
&:focus {
+ div::before {
outline: 2px solid Highlight;
outline-style: auto;
outline-color: -webkit-focus-ring-color;
}
}
}
}
</style>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/elements";
h2 {
font-size: 20px;
}
thead {
user-select: none;
-webkit-user-select: none;
color: var(--background-color);
text-transform: uppercase;
cursor: pointer;
background-color: var(--text-color);
letter-spacing: 0.8px;
font-size: 1rem;
border: 1px solid var(--text-color-90);
th:first-of-type {
border-top-left-radius: 8px;
}
th:last-of-type {
border-top-right-radius: 8px;
}
}
tbody {
tr > td:first-of-type {
white-space: unset;
}
tr > td:not(td:first-of-type) {
text-align: center;
}
tr > td:last-of-type {
cursor: pointer;
}
tr td:first-of-type {
border-left: 1px solid var(--text-color-90);
}
tr td:last-of-type {
border-right: 1px solid var(--text-color-90);
}
tr:last-of-type {
td {
border-bottom: 1px solid var(--text-color-90);
}
td:first-of-type {
border-bottom-left-radius: 8px;
}
td:last-of-type {
border-bottom-right-radius: 8px;
}
}
tr:nth-child(even) {
background-color: var(--background-70);
}
}
th,
td {
padding: 0.35rem 0.25rem;
white-space: nowrap;
svg {
width: 24px;
fill: var(--text-color);
}
}
.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: 20px;
// color: $green;
text-align: center;
margin: 0;
.icon {
vertical-align: text-top;
margin-left: 1rem;
fill: var(--text-color);
width: 22px;
height: 22px;
// stroke: white !important;
}
&.editable {
cursor: pointer;
}
}
&-editIcon {
margin-left: 10px;
margin-top: -3px;
width: 22px;
height: 22px;
&:hover {
fill: $green;
cursor: pointer;
}
}
}
table {
// border-collapse: collapse;
border-spacing: 0;
margin-top: 1rem;
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;
margin-bottom: 1rem;
@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>

View File

@@ -1,13 +1,12 @@
<template>
<transition name="shut">
<ul class="dropdown">
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<li
v-for="result in searchResults"
:key="`${result.index}-${result.title}-${result.type}`"
v-for="(result, _index) in searchResults"
:key="`${_index}-${result.title}-${result.type}`"
:class="`result di-${_index} ${_index === index ? 'active' : ''}`"
@click="openPopup(result)"
:class="
`result di-${result.index} ${result.index === index ? 'active' : ''}`
"
>
<IconMovie v-if="result.type == 'movie'" class="type-icon" />
<IconShow v-if="result.type == 'show'" class="type-icon" />
@@ -24,239 +23,243 @@
</transition>
</template>
<script>
import { mapActions } from "vuex";
import IconMovie from "src/icons/IconMovie";
import IconShow from "src/icons/IconShow";
import IconPerson from "src/icons/IconPerson";
import { elasticSearchMoviesAndShows } from "@/api";
<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";
export default {
components: { IconMovie, IconShow, IconPerson },
props: {
query: {
type: String,
default: null,
required: false
},
index: {
type: Number,
default: -1,
required: false
},
results: {
type: Array,
default: [],
required: false
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 */
}
},
watch: {
query(newQuery) {
if (newQuery && newQuery.length > 1) this.fetchAutocompleteResults();
}
},
data() {
return {
searchResults: [],
keyboardNavigationIndex: 0,
numberOfResults: 10
};
},
methods: {
...mapActions("popup", ["open"]),
openPopup(result) {
const { id, type } = result;
this.open({ id, type });
},
fetchAutocompleteResults() {
this.keyboardNavigationIndex = 0;
this.searchResults = [];
);
elasticSearchMoviesAndShows(this.query, this.numberOfResults).then(
resp => {
const data = resp.hits.hits;
function openPopup(result) {
if (!result.id || !result.type) return;
let results = data.map(item => {
let index = null;
if (item._source.log.file.path.includes("movie")) index = "movie";
if (item._source.log.file.path.includes("series")) index = "show";
store.dispatch("popup/open", { ...result });
}
if (index === "movie" || index === "show") {
return {
title:
item._source.original_name || item._source.original_title,
id: item._source.id,
adult: item._source.adult,
type: index
};
}
});
results = this.removeDuplicates(results);
results = results.map((el, index) => {
return { ...el, index };
});
this.$emit("update:results", results);
this.searchResults = results;
}
function removeDuplicates(_searchResults) {
const filteredResults = [];
_searchResults.forEach(result => {
if (result === undefined) return;
const numberOfDuplicates = filteredResults.filter(
filterItem => filterItem.id === result.id
);
},
removeDuplicates(searchResults) {
let filteredResults = [];
searchResults.map(result => {
if (result === undefined) return;
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
);
if (numberOfDuplicates.length >= 1) {
return;
}
return filteredResults;
}
},
created() {
if (this.query) this.fetchAutocompleteResults();
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;
@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);
@for $i from 0 through $sizes {
.dropdown .di-#{$i} {
visibility: visible;
transform-origin: top center;
animation: scaleZ 200ms calc(50ms * #{$i}) ease-in forwards;
}
}
.type-icon {
width: 28px;
height: 28px;
margin-right: 1rem;
transition: inherit;
fill: var(--text-color-50);
@keyframes scaleZ {
0% {
opacity: 0;
transform: scale(0);
}
80% {
transform: scale(1.07);
}
100% {
opacity: 1;
transform: scale(1);
}
}
}
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%;
}
.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;
.shut-leave-to {
height: 0px;
transition: height 0.4s ease;
flex-wrap: no-wrap;
overflow: hidden;
}
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>

View File

@@ -1,8 +1,10 @@
<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>
@@ -10,10 +12,10 @@
<SearchInput />
<Hamburger class="mobile-only" />
<NavigationIcon class="desktop-only" :route="profileRoute" />
<div class="navigation-icons-grid mobile-only" :class="{ open: isOpen }">
<!-- <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>
@@ -21,106 +23,103 @@
</nav>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
import TmdbLogo from "@/icons/tmdb-logo";
import IconProfile from "@/icons/IconProfile";
import IconProfileLock from "@/icons/IconProfileLock";
import IconSettings from "@/icons/IconSettings";
import IconActivity from "@/icons/IconActivity";
import SearchInput from "@/components/header/SearchInput";
import NavigationIcons from "src/components/header/NavigationIcons";
import NavigationIcon from "src/components/header/NavigationIcon";
import Hamburger from "@/components/ui/Hamburger";
<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";
export default {
components: {
NavigationIcons,
NavigationIcon,
SearchInput,
TmdbLogo,
IconProfile,
IconProfileLock,
IconSettings,
IconActivity,
Hamburger
},
computed: {
...mapGetters("user", ["loggedIn"]),
...mapGetters("hamburger", ["isOpen"]),
isHome() {
return this.$route.path === "/";
},
profileRoute() {
return {
title: !this.loggedIn ? "Signin" : "Profile",
route: !this.loggedIn ? "/signin" : "/profile",
icon: !this.loggedIn ? IconProfileLock : IconProfile
};
}
}
};
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";
@import "src/scss/variables";
@import "src/scss/media-queries";
.spacer {
@include mobile-only {
.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%;
height: $header-size;
}
}
background-color: $background-95;
visibility: hidden;
opacity: 0;
transition: opacity 0.4s ease;
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;
&.open {
opacity: 1;
visibility: visible;
&.open {
}
}
}
</style>

View File

@@ -1,81 +1,97 @@
<template>
<router-link
:to="{ path: route.route }"
:key="route.title"
v-if="route.requiresAuth == undefined || (route.requiresAuth && loggedIn)"
v-if="route?.requiresAuth == undefined || (route?.requiresAuth && loggedIn)"
:key="route?.title"
:to="{ path: route?.route }"
>
<li class="navigation-link" :class="{ active: route.route == active }">
<component class="navigation-icon" :is="route.icon"></component>
<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>
import { mapGetters, mapActions } from "vuex";
<script setup lang="ts">
import { useStore } from "vuex";
import { computed, defineProps } from "vue";
import type INavigationIcon from "../../interfaces/INavigationIcon";
export default {
name: "NavigationIcon",
props: {
active: {
type: String,
required: false
},
route: {
type: Object,
required: true
}
},
computed: {
...mapGetters("user", ["loggedIn"])
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";
@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;
.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 {
transform: scale(1.05);
}
&:hover,
&.active {
background-color: var(--background-color);
&:hover,
&.active {
background-color: var(--background-color);
span,
.navigation-icon {
color: var(--text-color);
fill: var(--text-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);
}
}
}
span {
text-transform: uppercase;
font-size: 11px;
margin-top: 0.25rem;
color: var(--text-color-70);
a {
text-decoration: none;
}
}
a {
text-decoration: none;
}
.navigation-icon {
width: 28px;
fill: var(--text-color-70);
transition: inherit;
}
.navigation-icon {
width: 28px;
fill: var(--text-color-70);
transition: inherit;
}
</style>

View File

@@ -1,103 +1,99 @@
<template>
<ul class="navigation-icons">
<NavigationIcon
v-for="route in routes"
:key="route.route"
:route="route"
v-for="_route in routes"
:key="_route.route"
:route="_route"
:active="activeRoute"
:useStroke="_route?.useStroke"
/>
<slot></slot>
</ul>
</template>
<script>
import NavigationIcon from "@/components/header/NavigationIcon";
import IconInbox from "@/icons/IconInbox";
import IconNowPlaying from "@/icons/IconNowPlaying";
import IconPopular from "@/icons/IconPopular";
import IconUpcoming from "@/icons/IconUpcoming";
import IconSettings from "@/icons/IconSettings";
import IconActivity from "@/icons/IconActivity";
<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";
export default {
name: "NavigationIcons",
components: {
NavigationIcon,
IconInbox,
IconPopular,
IconNowPlaying,
IconUpcoming,
IconSettings,
IconActivity
},
data() {
return {
routes: [
{
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,
icon: IconActivity
},
{
title: "Settings",
route: "/profile?settings=true",
requiresAuth: true,
icon: IconSettings
}
],
activeRoute: null
};
},
watch: {
$route() {
this.activeRoute = window.location.pathname;
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
}
},
created() {
this.activeRoute = window.location.pathname;
];
function setActiveRoute(_route: string) {
activeRoute.value = _route;
}
};
watch(route, () => setActiveRoute(window?.location?.pathname || ""));
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
@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%;
.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 desktop {
grid-template-rows: var(--header-size);
}
@include mobile {
grid-template-columns: 1fr 1fr;
}
}
@include mobile {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -1,281 +1,290 @@
<template>
<div>
<div class="search" :class="{ active: focusingInput }">
<div class="search" :class="{ active: inputIsActive }">
<IconSearch class="search-icon" tabindex="-1" />
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
<input
ref="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"
v-model="query"
@input="handleInput"
@click="focusingInput = true"
@click="focus"
@keydown.escape="handleEscape"
@keyup.enter="handleSubmit"
@keydown.up="navigateUp"
@keydown.down="navigateDown"
@focus="focusingInput = true"
@blur="focusingInput = false"
@focus="focus"
@blur="blur"
/>
<IconClose
v-if="query && query.length"
tabindex="0"
aria-label="button"
v-if="query && query.length"
@click="resetQuery"
@keydown.enter.stop="resetQuery"
class="close-icon"
@click="clearInput"
@keydown.enter.stop="clearInput"
/>
</div>
<AutocompleteDropdown
v-if="showAutocompleteResults"
v-model:results="dropdownResults"
:query="query"
:index="dropdownIndex"
:results.sync="dropdownResults"
/>
</div>
</template>
<script>
import { mapActions, mapGetters } from "vuex";
import SeasonedButton from "@/components/ui/SeasonedButton";
import AutocompleteDropdown from "@/components/header/AutocompleteDropdown";
<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";
import IconSearch from "src/icons/IconSearch";
import IconClose from "src/icons/IconClose";
import config from "@/config";
interface ISearchResult {
title: string;
id: number;
adult: boolean;
type: MediaTypes;
}
export default {
name: "SearchInput",
components: {
SeasonedButton,
AutocompleteDropdown,
IconClose,
IconSearch
},
data() {
return {
query: null,
disabled: false,
dropdownIndex: -1,
dropdownResults: [],
focusingInput: false,
showAutocomplete: false
};
},
computed: {
...mapGetters("popup", ["isOpen"]),
showAutocompleteResults() {
return (
!this.disabled &&
this.focusingInput &&
this.query &&
this.query.length > 0
);
}
},
created() {
const params = new URLSearchParams(window.location.search);
if (params && params.has("query")) {
this.query = decodeURIComponent(params.get("query"));
}
const store = useStore();
const router = useRouter();
const route = useRoute();
const elasticUrl = config.ELASTIC_URL;
if (elasticUrl === undefined || elasticUrl === false || elasticUrl === "") {
this.disabled = true;
}
},
methods: {
...mapActions("popup", ["open"]),
navigateDown() {
if (this.dropdownIndex < this.dropdownResults.length - 1) {
this.dropdownIndex++;
}
},
navigateUp() {
if (this.dropdownIndex > -1) this.dropdownIndex--;
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 input = this.$refs.input;
const textLength = input.value.length;
const isOpen = computed(() => store.getters["popup/isOpen"]);
const showAutocompleteResults = computed(() => {
return (
!disabled.value &&
inputIsActive.value &&
query.value &&
query.value.length > 0
);
});
setTimeout(() => {
input.focus();
input.setSelectionRange(textLength, textLength + 1);
}, 1);
},
search() {
const encodedQuery = encodeURI(this.query.replace('/ /g, "+"'));
const params = new URLSearchParams(window.location.search);
if (params && params.has("query")) {
query.value = decodeURIComponent(params.get("query"));
}
this.$router.push({
name: "search",
query: {
...this.$route.query,
query: encodedQuery
}
});
},
resetQuery(event) {
this.query = "";
this.$refs.input.focus();
},
handleInput(e) {
this.$emit("input", this.query);
this.dropdownIndex = -1;
},
handleSubmit() {
if (!this.query || this.query.length == 0) return;
const { ELASTIC } = process.env;
if (ELASTIC === undefined || ELASTIC === "") {
disabled.value = true;
}
if (this.dropdownIndex >= 0) {
const resultItem = this.dropdownResults[this.dropdownIndex];
console.log("resultItem:", resultItem);
this.open({
id: resultItem.id,
type: resultItem.type
});
return;
}
this.search();
this.$refs.input.blur();
this.dropdownIndex = -1;
},
handleEscape() {
if (!this.isOpen) {
this.$refs.input.blur();
this.dropdownIndex = -1;
}
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";
@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;
.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;
@include tablet-min {
right: 6px;
}
}
}
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;
.filter {
width: 100%;
right: 0px;
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;
}
}
}
input {
hr {
display: block;
width: 100%;
padding: 13px 28px 13px 45px;
outline: none;
margin: 0;
height: 1px;
border: 0;
background-color: $background-color-secondary;
font-weight: 300;
font-size: 18px;
color: $text-color;
border-bottom: 1px solid transparent;
border-bottom: 1px solid $text-color-50;
margin-top: 10px;
margin-bottom: 10px;
width: 90%;
}
&:focus {
// border-bottom: 1px solid var(--color-green);
.search.active {
input {
border-color: var(--color-green);
}
@include tablet-min {
font-size: 24px;
padding: 13px 40px 13px 60px;
.search-icon {
fill: var(--color-green);
}
}
&-icon {
width: 20px;
height: 20px;
fill: var(--text-color-50);
pointer-events: none;
position: absolute;
left: 15px;
top: calc(50% - 10px);
.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 {
width: 24px;
height: 24px;
top: calc(50% - 12px);
left: 22px;
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>

View File

@@ -1,82 +1,86 @@
<template>
<li
class="sidebar-list-element"
@click="event => $emit('click', event)"
:class="{ active, disabled }"
@click="emit('click')"
@keydown.enter="emit('click')"
>
<slot></slot>
</li>
</template>
<script>
export default {
props: {
active: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
<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";
@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;
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);
&:first-of-type {
padding-top: 0;
}
div > svg,
svg {
fill: var(--text-color);
transform: scale(1.1, 1.1);
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;
}
}
&.active > div > svg,
&.active > svg {
fill: var(--color-green);
}
&.disabled {
cursor: default;
}
.pending {
color: #f8bd2d;
}
.meta {
margin-left: auto;
text-align: right;
}
}
</style>

View File

@@ -1,10 +1,11 @@
<template>
<div
id="description"
ref="descriptionElement"
class="movie-description noselect"
@click="overflow ? (truncated = !truncated) : null"
@keydown.enter="overflow ? (truncated = !truncated) : null"
>
<span ref="description" :class="{ truncated }">{{ description }}</span>
<span :class="{ truncated }">{{ description }}</span>
<button v-if="description && overflow" class="truncate-toggle">
<IconArrowDown :class="{ rotate: !truncated }" />
@@ -12,113 +13,115 @@
</div>
</template>
<script>
import IconArrowDown from "../../icons/IconArrowDown";
export default {
components: { IconArrowDown },
props: {
description: {
type: String,
required: true
}
},
data() {
return {
truncated: true,
overflow: false
};
},
mounted() {
this.checkDescriptionOverflowing();
},
methods: {
checkDescriptionOverflowing() {
const descriptionEl = document.getElementById("description");
if (!descriptionEl) return;
<script setup lang="ts">
import { ref, defineProps, onMounted } from "vue";
import type { Ref } from "vue";
import IconArrowDown from "../../icons/IconArrowDown.vue";
const { height, width } = descriptionEl.getBoundingClientRect();
const { fontSize, lineHeight } = getComputedStyle(descriptionEl);
const elementWithoutOverflow = document.createElement("div");
elementWithoutOverflow.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
elementWithoutOverflow.classList.add("dummy-non-overflow");
elementWithoutOverflow.innerText = this.description;
document.body.appendChild(elementWithoutOverflow);
const elemWithoutOverflowHeight =
elementWithoutOverflow.getBoundingClientRect()["height"];
this.overflow = elemWithoutOverflowHeight > height;
this.removeElements(document.querySelectorAll(".dummy-non-overflow"));
},
removeElements: elems => elems.forEach(el => el.remove())
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";
@import "src/scss/media-queries";
.movie-description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
transition: all 1s ease;
.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);
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
}
&::before,
&::after {
content: "";
flex: 1;
border-bottom: 1px solid var(--text-color-50);
span.truncated {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
&::before {
margin-right: 1rem;
.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;
}
}
&::after {
margin-left: 1rem;
}
}
</style>

View File

@@ -7,52 +7,48 @@
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
required: true
},
detail: {
required: false,
default: null
}
<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";
@import "src/scss/media-queries";
.movie-detail {
margin-bottom: 20px;
.movie-detail {
margin-bottom: 20px;
&:last-of-type {
margin-bottom: 0px;
}
&:last-of-type {
margin-bottom: 0px;
}
@include tablet-min {
margin-bottom: 30px;
}
@include tablet-min {
margin-bottom: 30px;
}
h2.title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
font-size: 1.2rem;
color: var(--color-green);
h2.title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
font-size: 1.2rem;
color: var(--color-green);
@include mobile {
font-size: 1.1rem;
@include mobile {
font-size: 1.1rem;
}
}
span.info {
font-weight: 300;
font-size: 1rem;
letter-spacing: 0.8px;
margin-top: 5px;
}
}
span.info {
font-weight: 300;
font-size: 1rem;
letter-spacing: 0.8px;
margin-top: 5px;
}
}
</style>

View File

@@ -1,22 +1,24 @@
<template>
<section class="movie">
<!-- HEADER w/ POSTER -->
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<header
ref="header"
ref="backdropElement"
:class="compact ? 'compact' : ''"
@click="compact = !compact"
>
<figure class="movie__poster">
<img
class="movie-item__img is-loaded"
ref="poster-image"
src="/assets/placeholder.png"
class="movie-item__img is-loaded"
alt="Movie poster"
:src="poster"
/>
</figure>
<div v-if="movie" class="movie__title">
<h1>{{ movie.title || movie.name }}</h1>
<i>{{ movie.tagline }}</i>
<div v-if="media" class="movie__title">
<h1>{{ media.title }}</h1>
<i>{{ media.tagline }}</i>
</div>
<loading-placeholder v-else :count="2" />
</header>
@@ -25,28 +27,35 @@
<div class="movie__main">
<div class="movie__wrap movie__wrap--main">
<!-- SIDEBAR ACTIONS -->
<div class="movie__actions" v-if="movie">
<action-button :active="matched" :disabled="true">
<IconThumbsUp v-if="matched" />
<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 />
{{ !matched ? "Not yet available" : "Already available 🎉" }}
{{
!media?.exists_in_plex
? "Not yet available"
: "Already available 🎉"
}}
</action-button>
<action-button @click="sendRequest" :active="requested">
<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 ${this.type}?` : "Already requested" }}
{{ !requested ? `Request ${type}?` : "Already requested" }}
</action-button>
<action-button v-if="plexId && matched" @click="openInPlex">
<action-button
v-if="plexId && media?.exists_in_plex"
@click="openInPlex"
>
<IconPlay />
Open and watch in plex now!
</action-button>
<action-button
v-if="credits && credits.cast && credits.cast.length"
v-if="cast?.length"
:active="showCast"
@click="() => (showCast = !showCast)"
>
@@ -56,8 +65,8 @@
<action-button
v-if="admin === true"
@click="showTorrents = !showTorrents"
:active="showTorrents"
@click="showTorrents = !showTorrents"
>
<IconBinoculars />
Search for torrents
@@ -73,11 +82,11 @@
</div>
<!-- Loading placeholder -->
<div class="movie__actions text-input__loading" v-else>
<div v-else class="movie__actions text-input__loading">
<div
v-for="index in admin ? Array(4) : Array(3)"
class="movie__actions-link"
:key="index"
class="movie__actions-link"
>
<div
class="movie__actions-text text-input__loading--line"
@@ -94,451 +103,433 @@
</div>
<Description
v-if="!loading && movie && movie.overview"
:description="movie.overview"
v-if="!loading && media && media.overview"
:description="media.overview"
/>
<div class="movie__details" v-if="movie">
<div v-if="media" class="movie__details">
<Detail
v-if="movie.year"
v-if="media.year"
title="Release date"
:detail="movie.year"
:detail="media.year"
/>
<Detail v-if="movie.rating" title="Rating" :detail="movie.rating" />
<Detail
v-if="movie.type == 'show'"
v-if="media.type === MediaTypes.Movie && media.rating"
title="Rating"
:detail="media.rating"
/>
<Detail
v-if="media.type == MediaTypes.Show"
title="Seasons"
:detail="movie.seasons"
:detail="media.seasons"
/>
<Detail
v-if="movie.genres && movie.genres.length"
v-if="media.genres && media.genres.length"
title="Genres"
:detail="movie.genres.join(', ')"
:detail="media.genres.join(', ')"
/>
<Detail
v-if="
movie.production_status &&
movie.production_status !== 'Released'
media.production_status &&
media.production_status !== 'Released'
"
title="Production status"
:detail="movie.production_status"
:detail="media.production_status"
/>
<Detail
v-if="movie.runtime"
v-if="media.runtime"
title="Runtime"
:detail="humanMinutes(movie.runtime)"
:detail="humanMinutes(media.runtime)"
/>
</div>
</div>
<!-- TODO: change this classname, this is general -->
<div
class="movie__admin"
v-if="showCast && credits && credits.cast && credits.cast.length"
>
<div v-if="showCast && cast?.length" class="movie__admin">
<Detail title="cast">
<CastList :cast="credits.cast" />
<CastList :cast="cast" />
</Detail>
</div>
</div>
<!-- TORRENT LIST -->
<TorrentList
v-if="movie && admin"
:show="showTorrents"
:query="title"
:tmdb_id="id"
:admin="admin"
v-if="media && admin && showTorrents"
class="torrents"
:query="media?.title"
:tmdb-id="id"
></TorrentList>
</div>
</section>
</template>
<script>
import { mapGetters } from "vuex";
import img from "@/directives/v-image";
import IconProfile from "@/icons/IconProfile";
import IconThumbsUp from "@/icons/IconThumbsUp";
import IconThumbsDown from "@/icons/IconThumbsDown";
import IconInfo from "@/icons/IconInfo";
import IconRequest from "@/icons/IconRequest";
import IconRequested from "@/icons/IconRequested";
import IconBinoculars from "@/icons/IconBinoculars";
import IconPlay from "@/icons/IconPlay";
import TorrentList from "@/components/TorrentList";
import CastList from "@/components/CastList";
import Detail from "@/components/popup/Detail";
import ActionButton from "@/components/popup/ActionButton";
import Description from "@/components/popup/Description";
import store from "@/store";
import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder";
<script setup lang="ts">
import { ref, computed, defineProps } from "vue";
import { useStore } from "vuex";
import {
getMovie,
getShow,
getPerson,
getCredits,
request,
getRequestStatus,
watchLink
} from "@/api";
// 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 { MediaTypes } from "../../interfaces/IList";
export default {
// props: ['id', 'type'],
props: {
id: {
required: true,
type: Number
},
type: {
required: false,
type: String
}
},
components: {
Description,
Detail,
ActionButton,
IconProfile,
IconThumbsUp,
IconThumbsDown,
IconRequest,
IconRequested,
IconInfo,
IconBinoculars,
IconPlay,
TorrentList,
CastList,
LoadingPlaceholder
},
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,
requested: false,
showTorrents: false,
showCast: false,
credits: [],
compact: false,
loading: true
};
},
watch: {
id: function (val) {
this.fetchByType();
},
backdrop: function (backdrop) {
if (backdrop != null) {
const style = {
backgroundImage:
"url(" + this.ASSET_URL + this.ASSET_SIZES[1] + backdrop + ")"
};
import { humanMinutes } from "../../utils";
import {
getMovie,
getShow,
getMovieCredits,
getShowCredits,
request,
getRequestStatus
// watchLink
} from "../../api";
Object.assign(this.$refs.header.style, style);
}
}
},
computed: {
...mapGetters("user", ["loggedIn", "admin", "plexId"]),
numberOfTorrentResults: () => {
let numTorrents = store.getters["torrentModule/resultCount"];
return numTorrents !== null ? numTorrents + " results" : null;
}
},
methods: {
async fetchByType() {
try {
let response;
if (this.type === "movie") {
response = await getMovie(this.id, true, false);
} else if (this.type === "show") {
response = await getShow(this.id, false, false);
} else {
this.$router.push({ name: "404" });
}
this.parseResponse(response);
} catch (error) {
this.$router.push({ name: "404" });
}
// async get credits
getCredits(this.type, this.id).then(credits => (this.credits = credits));
},
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 = "/assets/no-image.svg";
return;
}
poster.src = `${this.ASSET_URL}${this.ASSET_SIZES[0]}${this.poster}`;
},
humanMinutes(minutes) {
if (minutes instanceof Array) {
minutes = minutes[0];
}
const hours = Math.floor(minutes / 60);
const minutesLeft = minutes - hours * 60;
if (minutesLeft == 0) {
return hours > 1 ? `${hours} hours` : `${hours} hour`;
} else if (hours == 0) {
return `${minutesLeft} min`;
}
return `${hours}h ${minutesLeft}m`;
},
sendRequest() {
request(this.id, this.type).then(resp => {
if (resp.success) {
this.requested = true;
}
});
},
openInPlex() {
watchLink(this.title, this.movie.year).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() {
store.dispatch("torrentModule/setResultCount", null);
this.prevDocumentTitle = store.getters["documentTitle/title"];
this.fetchByType();
},
beforeDestroy() {
store.dispatch("documentTitle/updateTitle", this.prevDocumentTitle);
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 plexId = computed(() => store.getters["user/plexId"]);
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(status: boolean) {
requested.value = status;
}
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(requestStatus => setRequested(requestStatus || false));
}
function setBackdrop(): void {
if (!media.value?.backdrop || !backdropElement.value?.style) return;
const backdropURL = `${ASSET_URL}${ASSET_SIZES[1]}${media.value.backdrop}`;
backdropElement.value.style.backgroundImage = `url(${backdropURL})`;
}
function sendRequest() {
request(props.id, props.type).then(resp =>
setRequested(resp?.success || false)
);
}
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();
setBackdrop();
store.dispatch("torrentModule/setResultCount", null);
// End on create functions
</script>
<style lang="scss" scoped>
@import "src/scss/loading-placeholder";
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
@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;
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;
@include mobile {
grid-template-columns: 1fr;
height: 250px;
place-items: center;
}
}
}
.movie__poster {
display: none;
* {
z-index: 2;
}
@include desktop {
background: var(--background-color);
height: auto;
display: block;
width: calc(100% - 80px);
margin: 40px;
> img {
&::before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
border-radius: 10px;
height: 100%;
background: $background-dark-85;
}
@include mobile {
&.compact {
height: 100px;
}
}
}
}
.movie {
&__wrap {
&--header {
align-items: center;
height: 100%;
.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;
}
}
&--main {
}
.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;
flex-direction: column;
@include tablet-min {
flex-direction: row;
}
background-color: $background-color;
color: $text-color;
}
}
> * {
margin-right: 30px;
&__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;
@include mobile {
margin-right: 20px;
}
}
}
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 {
&__admin {
width: 100%;
padding: 20px;
order: 2;
padding: 40px;
width: 55%;
margin-left: 45%;
@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;
}
}
}
}
&__info {
margin-left: 0;
}
&__details {
display: flex;
flex-wrap: wrap;
> * {
margin-right: 30px;
.torrents {
background-color: var(--background-color);
padding: 0 1rem;
@include mobile {
margin-right: 20px;
padding: 0 0.5rem;
}
}
}
&__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;
}
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -3,14 +3,14 @@
<header ref="header">
<div class="info">
<h1 v-if="person">
{{ person.title || person.name }}
{{ person.name }}
</h1>
<div v-else>
<loading-placeholder :count="1" />
<loading-placeholder :count="1" lineClass="short" :top="3.5" />
<loading-placeholder :count="1" line-class="short" :top="3.5" />
</div>
<span class="known-for" v-if="person && person['known_for_department']">
<span v-if="person && person['known_for_department']" class="known-for">
{{
person.known_for_department === "Acting"
? "Actor"
@@ -21,18 +21,19 @@
<figure class="person__poster">
<img
class="person-item__img is-loaded"
ref="poster-image"
src="/assets/placeholder.png"
class="person-item__img is-loaded"
alt="Image of person"
:src="poster"
/>
</figure>
</header>
<div v-if="loading">
<loading-placeholder :count="6" />
<loading-placeholder lineClass="short" :top="3" />
<loading-placeholder :count="6" lineClass="fullwidth" />
<loading-placeholder lineClass="short" :top="4.5" />
<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>
@@ -50,251 +51,228 @@
</Detail>
<Detail
v-if="creditedShows.length"
title="movies"
:detail="`Credited in ${movieCredits.length} movies`"
v-if="credits"
:detail="`Credited in ${creditedMovies.length} movies`"
>
<CastList :cast="movieCredits" />
<CastList :cast="creditedMovies" />
</Detail>
<Detail
v-if="creditedShows.length"
title="shows"
:detail="`Credited in ${showCredits.length} shows`"
v-if="credits"
:detail="`Credited in ${creditedShows.length} shows`"
>
<CastList :cast="showCredits" />
<CastList :cast="creditedShows" />
</Detail>
</div>
</section>
</template>
<script>
import img from "@/directives/v-image";
import CastList from "@/components/CastList";
import Detail from "@/components/popup/Detail";
import Description from "@/components/popup/Description";
import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder";
<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";
import { getPerson, getPersonCredits } from "@/api";
export default {
props: {
id: {
required: true,
type: Number
},
type: {
required: false,
type: String,
default: "person"
}
},
components: {
Detail,
Description,
CastList,
LoadingPlaceholder
},
directives: { img: img }, // TODO decide to remove or use
data() {
return {
ASSET_URL: "https://image.tmdb.org/t/p/",
ASSET_SIZES: ["w500", "w780", "original"],
person: undefined,
loading: true,
credits: undefined
};
},
watch: {
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: {
age: function () {
if (!this.person || !this.person.birthday) {
return;
}
const today = new Date().getFullYear();
const birthYear = new Date(this.person.birthday).getFullYear();
return `${today - birthYear} years old`;
},
movieCredits: function () {
const { cast } = this.credits;
if (!cast) return;
return cast
.filter(l => l.type === "movie")
.filter((item, pos, self) => self.indexOf(item) == pos)
.sort((a, b) => a.popularity < b.popularity);
},
showCredits: function () {
const { cast } = this.credits;
if (!cast) return;
const alreadyExists = (item, pos, self) => {
const names = self.map(item => item.title);
return names.indexOf(item.title) == pos;
};
return cast
.filter(item => item.type === "show")
.filter(alreadyExists)
.sort((a, b) => a.popularity < b.popularity);
}
},
methods: {
parseResponse(person) {
this.loading = false;
this.person = { ...person };
this.title = person.title;
this.poster = person.poster;
if (person.credits) this.credits = person.credits;
this.setPosterSrc();
},
setPosterSrc() {
const poster = this.$refs["poster-image"];
if (this.poster == null) {
poster.src = "/assets/no-image.svg";
return;
}
poster.src = `${this.ASSET_URL}${this.ASSET_SIZES[0]}${this.poster}`;
}
},
created() {
getPerson(this.id, false)
.then(this.parseResponse)
.catch(error => {
console.error(error);
this.$router.push({ name: "404" });
});
getPersonCredits(this.id)
.then(credits => (this.credits = credits))
.catch(error => {
console.error(error);
});
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";
@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);
section.person {
overflow: hidden;
position: relative;
padding: 40px;
background-color: var(--background-color);
@include mobile {
padding: 50px 20px 10px;
@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;
}
}
}
&:before {
content: "";
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;
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;
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 {
margin: auto;
width: fit-content;
border-radius: 10px;
width: 100%;
background-color: grey;
animation: pulse 1s infinite ease-in-out;
@include mobile {
max-width: 225px;
@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>

View File

@@ -0,0 +1,98 @@
<template>
<div>
<h3 class="settings__header">Change password</h3>
<form class="form">
<seasoned-input
v-model="oldPassword"
placeholder="old password"
icon="Keyhole"
type="password"
/>
<seasoned-input
v-model="newPassword"
placeholder="new password"
icon="Keyhole"
type="password"
/>
<seasoned-input
v-model="newPasswordRepeat"
placeholder="repeat new password"
icon="Keyhole"
type="password"
/>
<seasoned-button @click="changePassword">change password</seasoned-button>
</form>
<seasoned-messages v-model:messages="messages" />
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import SeasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import type { Ref } from "vue";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
// interface ResetPasswordPayload {
// old_password: string;
// new_password: string;
// }
const oldPassword: Ref<string> = ref("");
const newPassword: Ref<string> = ref("");
const newPasswordRepeat: Ref<string> = ref("");
const messages: Ref<IErrorMessage[]> = ref([]);
function addWarningMessage(message: string, title?: string) {
messages.value.push({
message,
title,
type: ErrorMessageTypes.Warning
} as IErrorMessage);
}
function validate() {
return new Promise((resolve, reject) => {
if (!oldPassword.value || oldPassword?.value?.length === 0) {
addWarningMessage("Missing old password!", "Validation error");
reject();
}
if (!newPassword.value || newPassword?.value?.length === 0) {
addWarningMessage("Missing new password!", "Validation error");
reject();
}
if (newPassword.value !== newPasswordRepeat.value) {
addWarningMessage(
"Password and password repeat do not match!",
"Validation error"
);
reject();
}
resolve(true);
});
}
// TODO seasoned-api /user/password-reset
async function changePassword() {
try {
validate();
} catch (error) {
console.log("not valid!"); // eslint-disable-line no-console
}
// const body: ResetPasswordPayload = {
// old_password: oldPassword.value,
// new_password: newPassword.value
// };
// const options = {};
// fetch()
}
</script>

View File

@@ -0,0 +1,114 @@
<template>
<div>
<h3 class="settings__header">Plex account</h3>
<div v-if="!plexId">
<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 plexId: ComputedRef<boolean> = computed(
() => store.getters["user/plexId"]
);
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>

View File

@@ -0,0 +1,6 @@
<template>
<code
>Monitor active torrents requested. Requires authentication with owners plex
library!</code
>
</template>

View File

@@ -0,0 +1,162 @@
<template>
<div v-if="query?.length" class="container">
<h2 class="torrent-header-text">
Searching for: <span class="query">{{ query }}</span>
</h2>
<loader v-if="loading" type="section" />
<div v-else>
<div v-if="torrents.length > 0" class="torrent-table">
<torrent-table :torrents="torrents" @magnet="addTorrent" />
<slot />
</div>
<div v-else class="no-results">
<h2>No results found</h2>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, inject, defineProps } from "vue";
import { useStore } from "vuex";
import Loader from "@/components/ui/Loader.vue";
import TorrentTable from "@/components/torrent/TorrentTable.vue";
import type { Ref } from "vue";
import { searchTorrents, addMagnet } from "../../api";
import type ITorrent from "../../interfaces/ITorrent";
interface Props {
query: string;
tmdbId?: number;
}
const loading: Ref<boolean> = ref(true);
const torrents: Ref<ITorrent[]> = ref([]);
const props = defineProps<Props>();
const store = useStore();
const notifications: {
info;
success;
error;
} = inject("notifications");
function setTorrents(_torrents: ITorrent[]) {
torrents.value = _torrents || [];
}
function setLoading(state: boolean) {
loading.value = state;
}
function updateResultCountDisplay() {
store.dispatch("torrentModule/setResults", torrents.value);
store.dispatch(
"torrentModule/setResultCount",
torrents.value?.length || -1
);
}
function fetchTorrents() {
if (!props.query?.length) return;
loading.value = true;
searchTorrents(props.query)
.then(torrentResponse => setTorrents(torrentResponse?.results))
.then(() => updateResultCountDisplay())
.finally(() => setLoading(false));
}
function addTorrent(torrent: ITorrent) {
const { name, magnet } = torrent;
notifications.info({
title: "Adding torrent 🧲",
description: props.query,
timeout: 3000
});
addMagnet(magnet, name, props.tmdbId)
.then(() => {
notifications.success({
title: "Torrent added 🎉",
description: props.query,
timeout: 3000
});
})
.catch(() => {
notifications.error({
title: "Failed to add torrent 🙅‍♀️",
description: "Check console for more info",
timeout: 3000
});
});
}
fetchTorrents();
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/elements";
h2 {
font-size: 20px;
}
.toggle {
max-width: unset !important;
margin: 1rem 0;
}
.container {
background-color: $background-color;
}
.no-results {
display: flex;
padding-bottom: 2rem;
justify-content: center;
flex-direction: column;
width: 100%;
align-items: center;
}
.torrent-header-text {
font-weight: 300;
text-transform: uppercase;
font-size: 20px;
color: var(--text-color);
text-align: center;
margin: 0;
.query {
font-weight: 500;
white-space: pre;
}
@include mobile {
text-align: left;
}
}
.download {
&__icon {
fill: $text-color-70;
height: 1.2rem;
&:hover {
fill: $text-color;
cursor: pointer;
}
}
&.active &__icon {
fill: $green;
}
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<table>
<thead class="table__header noselect">
<tr>
<th
v-for="column in columns"
:key="column"
:class="column === selectedColumn ? 'active' : null"
@click="sortTable(column)"
>
{{ column }}
<span v-if="prevCol === column && direction"></span>
<span v-if="prevCol === column && !direction"></span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="torrent in torrents"
:key="torrent.magnet"
class="table__content"
>
<td
@click="expand($event, torrent.name)"
@keydown.enter="expand($event, torrent.name)"
>
{{ torrent.name }}
</td>
<td
@click="expand($event, torrent.name)"
@keydown.enter="expand($event, torrent.name)"
>
{{ torrent.seed }}
</td>
<td
@click="expand($event, torrent.name)"
@keydown.enter="expand($event, torrent.name)"
>
{{ torrent.size }}
</td>
<td
class="download"
@click="() => emit('magnet', torrent)"
@keydown.enter="() => emit('magnet', torrent)"
>
<IconMagnet />
</td>
</tr>
</tbody>
</table>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits } from "vue";
import IconMagnet from "@/icons/IconMagnet.vue";
import type { Ref } from "vue";
import { sortableSize } from "../../utils";
import type ITorrent from "../../interfaces/ITorrent";
interface Props {
torrents: Array<ITorrent>;
}
interface Emit {
(e: "magnet", torrent: ITorrent): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emit>();
const columns: string[] = ["name", "seed", "size", "add"];
const torrents: Ref<ITorrent[]> = ref(props.torrents);
const direction: Ref<boolean> = ref(false);
const selectedColumn: Ref<string> = ref(columns[0]);
const prevCol: Ref<string> = ref("");
function expand(event: MouseEvent, text: string) {
const elementClicked = event.target as HTMLElement;
const tableRow = elementClicked.parentElement;
const scopedStyleDataVariable = Object.keys(tableRow.dataset)[0];
const existingExpandedElement =
document.getElementsByClassName("expanded")[0];
const clickedSameTwice =
existingExpandedElement?.previousSibling?.isEqualNode(tableRow);
if (existingExpandedElement) {
existingExpandedElement.remove();
// Clicked the same element twice, remove and return
// not recreate and collapse
if (clickedSameTwice) return;
}
const expandedRow = document.createElement("tr");
const expandedCol = document.createElement("td");
expandedRow.dataset[scopedStyleDataVariable] = "";
expandedCol.dataset[scopedStyleDataVariable] = "";
expandedRow.className = "expanded";
expandedCol.innerText = text;
expandedCol.colSpan = 4;
expandedRow.appendChild(expandedCol);
tableRow.insertAdjacentElement("afterend", expandedRow);
}
function sortName() {
const torrentsCopy = [...torrents.value];
if (direction.value) {
torrents.value = torrentsCopy.sort((a, b) => (a.name < b.name ? 1 : -1));
} else {
torrents.value = torrentsCopy.sort((a, b) => (a.name > b.name ? 1 : -1));
}
}
function sortSeed() {
const torrentsCopy = [...torrents.value];
if (direction.value) {
torrents.value = torrentsCopy.sort(
(a, b) => parseInt(a.seed, 10) - parseInt(b.seed, 10)
);
} else {
torrents.value = torrentsCopy.sort(
(a, b) => parseInt(b.seed, 10) - parseInt(a.seed, 10)
);
}
}
function sortSize() {
const torrentsCopy = [...torrents.value];
if (direction.value) {
torrents.value = torrentsCopy.sort(
(a, b) => sortableSize(a.size) - sortableSize(b.size)
);
} else {
torrents.value = torrentsCopy.sort(
(a, b) => sortableSize(b.size) - sortableSize(a.size)
);
}
}
function sortTable(col, sameDirection = false) {
if (prevCol.value === col && sameDirection === false) {
direction.value = !direction.value;
}
if (col === "name") sortName();
else if (col === "seed") sortSeed();
else if (col === "size") sortSize();
prevCol.value = col;
}
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/elements";
table {
border-spacing: 0;
margin-top: 0.5rem;
width: 100%;
// border-collapse: collapse;
border-radius: 0.5rem;
overflow: hidden;
}
th,
td {
border: 0.5px solid var(--background-color-40);
@include mobile {
white-space: nowrap;
padding: 0;
}
}
thead {
position: relative;
user-select: none;
-webkit-user-select: none;
color: var(--table-header-text-color);
text-transform: uppercase;
cursor: pointer;
background-color: var(--table-background-color);
// background-color: black;
// color: var(--color-green);
letter-spacing: 0.8px;
font-size: 1rem;
th:last-of-type {
padding-right: 0.4rem;
}
}
tbody {
// first column
tr td:first-of-type {
position: relative;
padding: 0 0.3rem;
cursor: default;
word-break: break-all;
border-left: 1px solid var(--table-background-color);
@include mobile {
max-width: 40vw;
overflow-x: hidden;
}
}
// all columns except first
tr td:not(td:first-of-type) {
text-align: center;
white-space: nowrap;
}
// last column
tr td:last-of-type {
vertical-align: middle;
cursor: pointer;
border-right: 1px solid var(--table-background-color);
svg {
width: 21px;
display: block;
margin: auto;
padding: 0.3rem 0;
fill: var(--text-color);
}
}
// alternate background color per row
tr:nth-child(even) {
background-color: var(--background-70);
}
// last element rounded corner border
tr:last-of-type {
td {
border-bottom: 1px solid var(--table-background-color);
}
td:first-of-type {
border-bottom-left-radius: 0.5rem;
}
td:last-of-type {
border-bottom-right-radius: 0.5rem;
}
}
}
.expanded {
padding: 0.25rem 1rem;
max-width: 100%;
border-left: 1px solid $text-color;
border-right: 1px solid $text-color;
border-bottom: 1px solid $text-color;
td {
white-space: normal;
word-break: break-all;
padding: 0.5rem 0.15rem;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div>
<torrent-search-results
:query="query"
:tmdb-id="tmdbId"
:class="{ truncated: truncated }"
><div
v-if="truncated"
class="load-more"
tabindex="0"
role="button"
@click="truncated = false"
@keydown.enter="truncated = false"
>
<icon-arrow-down />
</div>
</torrent-search-results>
<div class="edit-query-btn-container">
<seasonedButton @click="openInTorrentPage"
>View on torrent page</seasonedButton
>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps } from "vue";
import { useRouter } from "vue-router";
import TorrentSearchResults from "@/components/torrent/TorrentSearchResults.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import IconArrowDown from "@/icons/IconArrowDown.vue";
import type { Ref } from "vue";
interface Props {
query: string;
tmdbId?: number;
}
const props = defineProps<Props>();
const router = useRouter();
const truncated: Ref<boolean> = ref(true);
function openInTorrentPage() {
if (!props.query?.length) {
router.push("/torrents");
return;
}
router.push({ path: "/torrents", query: { query: props.query } });
}
</script>
<style lang="scss" scoped>
:global(.truncated .torrent-table) {
position: relative;
max-height: 500px;
overflow-y: hidden;
}
.load-more {
position: absolute;
display: flex;
align-items: flex-end;
justify-content: center;
bottom: 0rem;
width: 100%;
height: 3rem;
cursor: pointer;
background: linear-gradient(
to top,
var(--background-color) 20%,
var(--background-0) 100%
);
}
svg {
height: 30px;
fill: var(--text-color);
}
.edit-query-btn-container {
display: flex;
justify-content: center;
padding: 1rem;
}
</style>

View File

@@ -1,52 +1,48 @@
<template>
<div class="darkToggle">
<span @click="toggleDarkmode">{{ darkmodeToggleIcon }}</span>
<span @click="toggleDarkmode" @keydown.enter="toggleDarkmode">{{
darkmodeToggleIcon
}}</span>
</div>
</template>
<script>
export default {
data() {
return {
darkmode: this.systemDarkModeEnabled()
};
},
methods: {
toggleDarkmode() {
this.darkmode = !this.darkmode;
document.body.className = this.darkmode ? "dark" : "light";
},
systemDarkModeEnabled() {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle["colorScheme"] != null) {
return computedStyle.colorScheme.includes("dark");
}
return false;
}
},
computed: {
darkmodeToggleIcon() {
return this.darkmode ? "🌝" : "🌚";
<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;
.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;
}
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>

View File

@@ -2,81 +2,84 @@
<div
class="nav__hamburger"
:class="{ open: isOpen }"
tabindex="0"
@click="toggle"
@keydown.enter="toggle"
tabindex="0"
>
<div v-for="(_, index) in 3" :key="index" class="bar"></div>
</div>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
<script setup lang="ts">
import { computed } from "vue";
import { useStore } from "vuex";
export default {
computed: { ...mapGetters("hamburger", ["isOpen"]) },
methods: { ...mapActions("hamburger", ["toggle"]) }
};
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";
@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);
.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;
}
@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;
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) {
transform: rotate(-45deg);
left: 16px;
top: 25px;
&:after {
content: "";
position: absolute;
left: 0px;
top: 0px;
width: 23px;
height: 1px;
transition: all 300ms ease;
}
}
&:nth-child(2):after {
transform: rotate(-90deg);
background-color: var(--text-color-70);
&: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>

View File

@@ -1,48 +1,68 @@
<template>
<div class="loader">
<div :class="`loader type-${type || LoaderHeightType.Page}`">
<i class="loader--icon">
<i class="loader--icon-spinner" />
</i>
</div>
</template>
<!--
TODO: fetch and display movie facts after 1.5 seconds while loading?
--></template>
<script setup lang="ts">
import { defineProps } from "vue";
import LoaderHeightType from "../../interfaces/ILoader";
interface Props {
type?: LoaderHeightType;
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/variables";
.loader {
display: flex;
width: 100%;
height: 30vh;
justify-content: center;
align-items: center;
.loader {
display: flex;
width: 100%;
height: 30vh;
justify-content: center;
align-items: center;
&--icon {
border: 2px solid $text-color-70;
border-radius: 50%;
display: block;
height: 40px;
position: absolute;
width: 40px;
&.type-section {
height: 15vh;
}
&-spinner {
&--icon {
border: 2px solid $text-color-70;
border-radius: 50%;
display: block;
animation: load 1s linear infinite;
height: 35px;
width: 35px;
&:after {
border: 7px solid $green-90;
border-radius: 50%;
content: "";
left: 8px;
position: absolute;
top: 22px;
height: 40px;
position: absolute;
width: 40px;
&-spinner {
display: block;
animation: load 1s linear infinite;
height: 35px;
width: 35px;
&:after {
border: 7px solid $green-90;
border-radius: 50%;
content: "";
left: 8px;
position: absolute;
top: 22px;
}
}
}
@keyframes load {
100% {
transform: rotate(360deg);
}
}
}
@keyframes load {
100% {
transform: rotate(360deg);
}
}
}
</style>

View File

@@ -1,34 +1,26 @@
<template>
<div class="text-input__loading" :style="`margin-top: ${top}rem`">
<div class="text-input__loading" :style="`margin-top: ${top || 0}rem`">
<div
class="text-input__loading--line"
:class="lineClass"
v-for="l in Array(count)"
v-for="l in Array(count || 1)"
:key="l"
class="text-input__loading--line"
:class="lineClass || ''"
></div>
</div>
</template>
<script>
export default {
props: {
count: {
type: Number,
default: 1,
require: false
},
lineClass: {
type: String,
default: ""
},
top: {
type: Number,
default: 0
}
<script setup lang="ts">
import { defineProps } from "vue";
interface Props {
count?: number;
lineClass?: string;
top?: number;
}
};
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "src/scss/loading-placeholder";
@import "src/scss/loading-placeholder";
</style>

View File

@@ -1,84 +1,77 @@
<template>
<button
type="button"
@click="emit('click')"
:class="{ active: active, fullwidth: fullWidth }"
@click="emit('click')"
>
<slot></slot>
</button>
</template>
<script>
export default {
name: "seasonedButton",
props: {
active: {
type: Boolean,
default: false,
required: false
},
fullWidth: {
type: Boolean,
default: false,
required: false
}
},
methods: {
emit() {
this.$emit("click");
}
<script setup lang="ts">
import { defineProps, defineEmits } from "vue";
interface Props {
active?: boolean;
fullWidth?: boolean;
}
};
interface Emit {
(e: "click");
}
defineProps<Props>();
const emit = defineEmits<Emit>();
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/variables";
@import "src/scss/media-queries";
button {
display: inline-block;
border: 1px solid $text-color;
font-size: 11px;
font-weight: 300;
line-height: 1.5;
letter-spacing: 0.5px;
text-transform: uppercase;
min-height: 45px;
padding: 5px 10px 4px 10px;
margin: 0;
margin-right: 0.3rem;
color: $text-color;
background: $background-color-secondary;
cursor: pointer;
outline: none;
transition: background 0.5s ease, color 0.5s ease, border-color 0.5s ease;
button {
display: inline-block;
border: 1px solid $text-color;
font-size: 11px;
font-weight: 300;
line-height: 1.5;
letter-spacing: 0.5px;
text-transform: uppercase;
min-height: 45px;
padding: 5px 10px 4px 10px;
margin: 0;
margin-right: 0.3rem;
color: $text-color;
background: $background-color-secondary;
cursor: pointer;
outline: none;
transition: background 0.5s ease, color 0.5s ease, border-color 0.5s ease;
@include desktop {
font-size: 0.8rem;
padding: 6px 20px 5px 20px;
}
&.fullwidth {
font-size: 14px;
width: 40%;
@include mobile {
width: 60%;
@include desktop {
font-size: 0.8rem;
padding: 6px 20px 5px 20px;
}
}
&:focus,
&:active,
&.active {
background: $text-color;
color: $background-color;
}
&.fullwidth {
font-size: 14px;
width: 40%;
@media (hover: hover) {
&:hover {
@include mobile {
width: 60%;
}
}
&:focus,
&:active,
&.active {
background: $text-color;
color: $background-color;
}
@media (hover: hover) {
&:hover {
background: $text-color;
color: $background-color;
}
}
}
}
</style>

View File

@@ -1,134 +1,140 @@
<template>
<div class="group" :class="{ completed: value, focus }">
<div class="group" :class="{ completed: modelValue, focus }">
<component :is="inputIcon" v-if="inputIcon" />
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
<input
class="input"
:type="tempType || type"
@input="handleInput"
v-model="inputValue"
:type="toggledType || type || 'text'"
:placeholder="placeholder"
@keyup.enter="event => $emit('enter', event)"
:value="modelValue"
@input="handleInput"
@keyup.enter="event => emit('enter', event)"
@focus="focus = true"
@blur="focus = false"
/>
<i
v-if="value && type === 'password'"
@click="toggleShowPassword"
@keydown.enter="toggleShowPassword"
v-if="modelValue && type === 'password'"
class="show noselect"
tabindex="0"
>{{ tempType == "password" ? "show" : "hide" }}</i
@click="toggleShowPassword"
@keydown.enter="toggleShowPassword"
>{{ toggledType == "password" ? "show" : "hide" }}</i
>
</div>
</template>
<script>
import IconKey from "../../icons/IconKey";
import IconEmail from "../../icons/IconEmail";
<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";
export default {
components: { IconKey, IconEmail },
props: {
placeholder: { type: String },
type: { type: String, default: "text" },
value: { type: String, default: undefined }
},
data() {
return {
inputValue: this.value || undefined,
tempType: this.type,
focus: false
};
},
computed: {
inputIcon() {
if (this.type === "password") return IconKey;
if (this.type === "email") return IconEmail;
return false;
}
},
methods: {
handleInput(event) {
if (this.value !== undefined) {
this.$emit("update:value", this.inputValue);
} else {
this.$emit("change", this.inputValue, event);
}
},
toggleShowPassword() {
if (this.tempType === "text") {
this.tempType = "password";
} else {
this.tempType = "text";
}
interface Props {
modelValue: string;
placeholder: string;
type?: string;
}
interface Emit {
(e: "change");
(e: "enter", event?: KeyboardEvent);
(e: "update:modelValue", value: string);
}
const props = defineProps<Props>();
const emit = defineEmits<Emit>();
const toggledType: Ref<string> = ref(props.type);
const focus: Ref<boolean> = ref(false);
const inputIcon = computed(() => {
if (props.type === "password") return IconKey;
if (props.type === "email") return IconEmail;
if (props.type === "torrents") return IconBinoculars;
return false;
});
function handleInput(event: KeyboardEvent) {
const target = event?.target as HTMLInputElement;
if (!target) return;
emit("update:modelValue", target?.value);
}
// Could we move this to component that injects ??
function toggleShowPassword() {
if (toggledType.value === "text") {
toggledType.value = "password";
} else {
toggledType.value = "text";
}
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/variables";
@import "src/scss/media-queries";
.group {
display: flex;
width: 100%;
position: relative;
max-width: 35rem;
border: 1px solid var(--text-color-50);
background-color: var(--background-color-secondary);
.group {
display: flex;
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);
&.completed,
&.focus,
&:hover,
&:focus {
border-color: var(--text-color);
svg {
fill: var(--text-color);
}
}
svg {
fill: var(--text-color);
width: 24px;
height: 24px;
fill: var(--text-color-50);
pointer-events: none;
margin-top: 10px;
margin-left: 10px;
z-index: 8;
}
input {
width: 100%;
padding: 10px;
outline: none;
background-color: var(--background-color-secondary);
color: var(--text-color);
font-weight: 100;
font-size: 1.2rem;
margin: 0;
z-index: 3;
border: none;
border-radius: 0;
-webkit-appearance: none;
}
.show {
position: absolute;
display: grid;
place-items: center;
right: 20px;
z-index: 11;
margin: auto 0;
height: 100%;
font-size: 0.9rem;
cursor: pointer;
color: var(--text-color-50);
-webkit-user-select: none;
user-select: none;
}
}
svg {
width: 24px;
height: 24px;
fill: var(--text-color-50);
pointer-events: none;
margin-top: 10px;
margin-left: 10px;
z-index: 8;
}
input {
width: 100%;
padding: 10px;
outline: none;
background-color: var(--background-color-secondary);
color: var(--text-color);
font-weight: 100;
font-size: 1.2rem;
margin: 0;
z-index: 3;
border: none;
border-radius: 0;
-webkit-appearance: none;
}
.show {
position: absolute;
display: grid;
place-items: center;
right: 20px;
z-index: 11;
margin: auto 0;
height: 100%;
font-size: 0.9rem;
cursor: pointer;
color: var(--text-color-50);
-webkit-user-select: none;
user-select: none;
}
}
</style>

View File

@@ -1,165 +1,173 @@
<template>
<transition-group name="fade">
<div
class="message"
v-for="(message, index) in reversedMessages"
:key="`${index}-${message.title}-${message.type}}`"
v-for="(message, index) in messages"
:key="generateMessageKey(index, message)"
class="card"
:class="message.type || 'warning'"
>
<span class="pinstripe"></span>
<div>
<div class="content">
<h2 class="title">
{{ message.title || defaultTitles[message.type] }}
{{ message.title || titleFromType(message.type) }}
</h2>
<span v-if="message.message" class="message">{{
message.message
}}</span>
</div>
<button class="dismiss" @click="clicked(message)">X</button>
<button class="dismiss" @click="dismiss(Number(index))">X</button>
</div>
</transition-group>
</template>
<script>
export default {
props: {
messages: {
required: true,
type: Array
}
},
data() {
return {
defaultTitles: {
error: "Unexpected error",
warning: "Something went wrong",
undefined: "Something went wrong"
},
localMessages: [...this.messages]
};
},
computed: {
reversedMessages() {
return [...this.messages].reverse();
}
},
methods: {
clicked(e) {
const removedMessage = [...this.messages].filter(mes => mes !== e);
this.$emit("update:messages", removedMessage);
}
<script setup lang="ts">
import { defineProps, defineEmits } from "vue";
import type {
ErrorMessageTypes,
IErrorMessage
} from "../../interfaces/IErrorMessage";
interface Props {
messages: IErrorMessage[];
}
interface Emit {
(e: "update:messages", messages: IErrorMessage[]);
}
const props = defineProps<Props>();
const emit = defineEmits<Emit>();
const defaultTitles = {
error: "Unexpected error",
warning: "Something went wrong",
success: "Success!",
undefined: "Something went wrong"
};
function titleFromType(type: ErrorMessageTypes) {
return defaultTitles[type];
}
function dismiss(index: number) {
const _messages = [...props.messages];
_messages.splice(index, 1);
emit("update:messages", _messages);
}
function generateMessageKey(
index: string | number | symbol,
errorMessage: IErrorMessage
) {
return `${String(index)}-${errorMessage.title}-${errorMessage.type}`;
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/variables";
@import "src/scss/media-queries";
.fade-enter-active {
transition: opacity 0.4s;
}
.fade-leave-active {
transition: opacity 0.1s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.fade-active {
transition: opacity 0.4s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.message {
width: 100%;
max-width: 35rem;
display: flex;
margin-top: 1rem;
margin-bottom: 1rem;
color: $text-color-70;
> div {
margin: 10px 24px;
.card {
width: 100%;
}
max-width: 35rem;
.title {
font-weight: 300;
letter-spacing: 0.25px;
margin: 0;
font-size: 1.3rem;
color: $text-color;
transition: color 0.5s ease;
}
.message {
font-weight: 300;
display: flex;
margin-top: 0.8rem;
color: $text-color-70;
transition: color 0.5s ease;
margin: 0.2rem 0 0.5rem;
}
@include mobile-only {
> div {
margin: 6px 6px;
line-height: 1.3rem;
.content {
margin: 0.4rem 1.2rem;
width: 100%;
.title {
font-weight: 300;
letter-spacing: 0.25px;
margin: 0;
font-size: 1.3rem;
color: $text-color;
transition: color 0.5s ease;
}
.message {
font-weight: 400;
font-size: 1.2rem;
color: $text-color-70;
transition: color 0.5s ease;
margin-bottom: 0.2rem;
}
@include mobile-only {
margin: 6px 6px;
line-height: 1.3rem;
h2 {
font-size: 1.1rem;
}
span {
font-size: 0.9rem;
}
}
}
h2 {
font-size: 1.1rem;
}
span {
font-size: 0.9rem;
}
}
.pinstripe {
width: 0.5rem;
background-color: $color-error-highlight;
}
.dismiss {
position: relative;
-webkit-appearance: none;
-moz-appearance: none;
background-color: transparent;
border: unset;
font-size: 18px;
cursor: pointer;
top: 0;
float: right;
height: 1.5rem;
width: 1.5rem;
padding: 0;
margin-top: 0.5rem;
margin-right: 0.5rem;
color: $text-color-70;
transition: color 0.5s ease;
&:hover {
color: $text-color;
}
}
&.success {
background-color: $color-success;
.pinstripe {
background-color: $color-success-highlight;
}
}
&.error {
background-color: $color-error;
.pinstripe {
width: 0.5rem;
background-color: $color-error-highlight;
}
}
&.warning {
background-color: $color-warning;
.dismiss {
position: relative;
-webkit-appearance: none;
-moz-appearance: none;
background-color: transparent;
border: unset;
font-size: 18px;
cursor: pointer;
.pinstripe {
background-color: $color-warning-highlight;
top: 0;
float: right;
height: 1.5rem;
width: 1.5rem;
padding: 0;
margin-top: 0.5rem;
margin-right: 0.5rem;
color: $text-color-70;
transition: color 0.5s ease;
&:hover {
color: $text-color;
}
}
&.success {
background-color: $color-success;
.pinstripe {
background-color: $color-success-highlight;
}
}
&.error {
background-color: $color-error;
.pinstripe {
background-color: $color-error-highlight;
}
}
&.warning {
background-color: $color-warning;
.pinstripe {
background-color: $color-warning-highlight;
}
}
}
}
</style>

View File

@@ -4,83 +4,73 @@
v-for="option in options"
:key="option"
class="toggle-button"
@click="toggle(option)"
:class="toggleValue === option ? 'selected' : null"
:class="selected === option ? 'selected' : null"
@click="() => toggleTo(option)"
>
{{ option }}
</button>
</div>
</template>
<script>
export default {
props: {
options: {
Array,
required: true
},
selected: {
type: String,
required: false,
default: undefined
}
},
data() {
return {
toggleValue: this.selected || this.options[0]
};
},
methods: {
toggle(toggleValue) {
this.toggleValue = toggleValue;
if (this.selected !== undefined) {
this.$emit("update:selected", toggleValue);
this.$emit("change", toggleValue);
} else {
this.$emit("change", toggleValue);
}
}
<script setup lang="ts">
import { defineProps, defineEmits } from "vue";
interface Props {
options: string[];
selected?: string;
}
interface Emit {
(e: "update:selected", selected: string);
(e: "change");
}
defineProps<Props>();
const emit = defineEmits<Emit>();
function toggleTo(option: string) {
emit("update:selected", option);
emit("change");
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/variables";
$background: $background-ui;
$background-selected: $background-color-secondary;
$background: $background-ui;
$background-selected: $background-color-secondary;
.toggle-container {
width: 100%;
display: flex;
overflow-x: scroll;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: $background;
border: 2px solid $background;
border-radius: 8px;
border-left: 4px solid $background;
border-right: 4px solid $background;
.toggle-button {
font-size: 1rem;
line-height: 1rem;
font-weight: normal;
padding: 0.5rem;
border: 0;
color: $text-color;
.toggle-container {
width: 100%;
display: flex;
overflow-x: scroll;
flex-direction: row;
justify-content: center;
align-items: center;
background-color: $background;
text-transform: capitalize;
cursor: pointer;
display: block;
flex: 1 0 auto;
border: 2px solid $background;
border-radius: 8px;
border-left: 4px solid $background;
border-right: 4px solid $background;
&.selected {
.toggle-button {
font-size: 1rem;
line-height: 1rem;
font-weight: normal;
padding: 0.5rem;
border: 0;
color: $text-color;
background-color: $background-selected;
border-radius: 8px;
background-color: $background;
text-transform: capitalize;
cursor: pointer;
display: block;
flex: 1 0 auto;
&.selected {
color: $text-color;
background-color: $background-selected;
border-radius: 8px;
}
}
}
}
</style>

View File

@@ -1,9 +0,0 @@
import type IConfig from "./interfaces/IConfig";
const config: IConfig = {
SEASONED_URL: "",
ELASTIC_URL: "https://elastic.kevinmidboe.com/",
ELASTIC_INDEX: "shows,movies"
};
export default config;

View File

@@ -1,5 +0,0 @@
{
"SEASONED_URL": "http://localhost:31459/api/",
"ELASTIC_URL": "http://localhost:9200",
"ELASTIC_INDEX": "shows,movies"
}

View File

@@ -0,0 +1,19 @@
<template>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-arrow-left-circle"
>
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 8 8 12 12 16"></polyline>
<line x1="16" y1="12" x2="8" y2="12"></line>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<template>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-arrow-right-circle"
>
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 16 16 12 12 8"></polyline>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
</template>

View File

@@ -1,15 +1,14 @@
<template>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="transition: stroke-width 0.5s ease"
>
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
</svg>

View File

@@ -1,7 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
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>

View File

@@ -1,15 +1,12 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
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
xmlns="http://www.w3.org/2000/svg"
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
xmlns="http://www.w3.org/2000/svg"
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>

View File

@@ -2,10 +2,11 @@
<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)"
style="transition-duration: 0s;"
>
<path
fill="inherit"

View File

@@ -1,11 +1,12 @@
<template>
<svg
@click="$emit('click')"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
@click="$emit('click')"
@keydown.enter="$emit('click')"
>
<path
xmlns="http://www.w3.org/2000/svg"
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>

View File

@@ -1,7 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
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>

View File

@@ -4,18 +4,18 @@
viewBox="0 0 32 32"
width="100%"
height="100%"
style="transition-duration: 0s;"
style="transition-duration: 0s"
>
<path
style="transition-duration: 0s;"
style="transition-duration: 0s"
d="M29.333 2.667h-26.667c-1.471 0-2.667 1.196-2.667 2.667v21.333c0 1.471 1.196 2.667 2.667 2.667h26.667c1.471 0 2.667-1.196 2.667-2.667v-21.333c0-1.471-1.196-2.667-2.667-2.667zM29.333 26.667h-26.667v-21.333h26.667v21.333c0.004 0 0 0 0 0z"
></path>
<path
style="transition-duration: 0s;"
style="transition-duration: 0s"
d="M11.333 17.058l-4.667 4.667v-1.725h-1.333v3.333c0 0.367 0.3 0.667 0.667 0.667h3.333v-1.333h-1.725l4.667-4.667-0.942-0.942z"
></path>
<path
style="transition-duration: 0s;"
style="transition-duration: 0s"
d="M26 8h-3.333v1.333h1.725l-4.667 4.667 0.942 0.942 4.667-4.667v1.725h1.333v-3.333c0-0.367-0.3-0.667-0.667-0.667z"
></path>
</svg>

View File

@@ -2,13 +2,12 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
fill="inherit"
xmlns="http://www.w3.org/2000/svg"
d="M31.671 16.054v0l-6.421-11.708c-0.117-0.213-0.342-0.346-0.583-0.346h-17.333c-0.242 0-0.467 0.133-0.583 0.346l-6.421 11.708c-0.208 0.379-0.329 0.817-0.329 1.279v8c0 1.471 1.196 2.667 2.667 2.667h26.667c1.471 0 2.667-1.196 2.667-2.667v-8c0-0.462-0.121-0.9-0.329-1.279zM29.333 25.333h-26.667v-8h8.167c0.592 2.296 2.683 4 5.167 4 2.479 0 4.571-1.704 5.167-4h8.167v8zM20.667 14.667c-0.367 0-0.667 0.3-0.667 0.667v0.667c0 2.204-1.796 4-4 4s-4-1.796-4-4v-0.667c0-0.367-0.3-0.667-0.667-0.667h-8.667c-0.021 0-0.038 0-0.058 0l5.121-9.333h16.546l5.121 9.333c-0.021 0-0.038 0-0.058 0h-8.671z"
/>
</svg>
<!-- <svg
xmlns="http://www.w3.org/2000/svg"
version="1.1" xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"

View File

@@ -1,7 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
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.258s4.254-0.425 6.229-1.258c1.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.229s-0.425-4.254-1.258-6.229zM27.438 9.15c-0.171 0.083-0.342 0.192-0.508 0.321l-0.087 0.067-0.063 0.092c-0.625 0.925-1.604 1.208-2.254 1.008-0.35-0.108-0.529-0.321-0.529-0.637 0-0.9-0.479-1.6-0.863-2.167-0.333-0.492-0.533-0.804-0.467-1.033 0.058-0.2 0.358-0.612 1.629-1.233 1.25 0.996 2.317 2.213 3.142 3.583zM16 2.667c0.271 0 0.538 0.008 0.8 0.025-0.133 0.087-0.292 0.158-0.471 0.242-0.521 0.237-1.238 0.567-1.613 1.483l-0.012 0.025-0.008 0.025c-0.858 2.717-2.008 3.783-2.771 4.487-0.546 0.504-1.017 0.942-1.025 1.667-0.008 0.629 0.329 1.296 1.242 2.458 0.729 0.929 1.429 1.4 2.142 1.45 0.9 0.058 1.533-0.558 2.046-1.054 0.258-0.25 0.525-0.512 0.725-0.579 0.054-0.017 0.175-0.058 0.479 0.242 1.108 1.108 2.012 1.4 2.675 1.617 0.613 0.2 0.892 0.292 1.204 0.887 0.521 0.987 1.308 1.371 1.883 1.65 0.629 0.304 0.708 0.383 0.708 0.708 0 0.212 0.008 0.442 0.012 0.679 0.025 0.85 0.058 2.137-0.325 2.533-0.054 0.058-0.146 0.121-0.354 0.121-1.329 0-1.863 1.183-2.217 1.967-0.1 0.225-0.267 0.587-0.375 0.704-0.871-0.121-1.938 0.875-3.592 2.492-0.367 0.358-0.85 0.833-1.233 1.167 0.042-0.442 0.142-1.104 0.358-2.046 0.292-1.279 0.708-2.658 1.008-3.354 0.196-0.454 0.25-1.163-0.608-1.942-0.462-0.417-1.1-0.792-1.721-1.154-0.446-0.258-0.863-0.504-1.183-0.746-0.358-0.271-0.425-0.408-0.433-0.433 0-0.246 0.046-0.525 0.088-0.821 0.171-1.113 0.425-2.796-1.821-3.779-0.233-0.104-0.479-0.2-0.713-0.292-1.879-0.742-3.817-1.508-4.162-6.667 2.392-2.325 5.662-3.763 9.267-3.763zM3.817 21.413c0.796 0.137 1.375-0.446 1.804-0.875 0.142-0.142 0.438-0.438 0.558-0.467 0.058 0.025 0.479 0.25 1.204 2.167 0.425 1.125 0.446 2.283 0.467 3.621 0.004 0.225 0.008 0.454 0.013 0.696-1.742-1.346-3.142-3.113-4.046-5.142zM9.229 27.483c-0.033-0.571-0.042-1.117-0.054-1.65-0.025-1.404-0.046-2.725-0.554-4.071-0.742-1.958-1.371-2.825-2.175-3-0.779-0.167-1.354 0.413-1.775 0.833-0.171 0.171-0.521 0.521-0.633 0.5-0.046-0.008-0.446-0.137-1.163-1.742-0.138-0.767-0.208-1.55-0.208-2.354 0-3.104 1.067-5.967 2.854-8.233 0.242 1.792 0.721 3.175 1.446 4.196 1.004 1.417 2.296 1.925 3.433 2.375 0.233 0.092 0.454 0.179 0.671 0.275 1.308 0.571 1.208 1.242 1.037 2.354-0.050 0.337-0.104 0.688-0.104 1.033 0 0.988 1.108 1.633 2.279 2.321 0.558 0.329 1.137 0.667 1.496 0.992 0.308 0.279 0.292 0.396 0.279 0.425-0.35 0.813-0.817 2.367-1.129 3.783-0.171 0.767-0.283 1.45-0.338 1.979-0.075 0.779-0.017 1.242 0.192 1.546 0.075 0.108 0.175 0.196 0.287 0.258-2.121-0.15-4.108-0.796-5.842-1.821zM16 29.333c-0.067 0-0.129 0-0.196 0 0.525-0.188 1.167-0.8 2.275-1.883 0.533-0.521 1.083-1.058 1.571-1.475 0.613-0.521 0.871-0.625 0.942-0.642 0.288 0.042 0.788 0.012 1.212-0.525 0.217-0.271 0.367-0.604 0.525-0.958 0.375-0.833 0.6-1.179 1.004-1.179 0.521 0 0.975-0.183 1.308-0.525 0.779-0.8 0.738-2.233 0.704-3.5-0.008-0.229-0.012-0.446-0.012-0.642 0-1.204-0.846-1.613-1.462-1.908-0.496-0.242-0.967-0.467-1.283-1.067-0.567-1.079-1.283-1.313-1.975-1.537-0.592-0.192-1.262-0.412-2.146-1.292-0.587-0.588-1.208-0.779-1.846-0.563-0.488 0.162-0.863 0.529-1.229 0.887-0.371 0.358-0.717 0.7-1.025 0.679-0.175-0.012-0.558-0.15-1.183-0.942-0.637-0.813-0.963-1.354-0.958-1.613 0.004-0.15 0.229-0.367 0.6-0.708 0.808-0.75 2.162-2.004 3.129-5.033 0.167-0.392 0.438-0.529 0.925-0.754 0.5-0.229 1.125-0.517 1.483-1.267 1.704 0.308 3.296 0.938 4.708 1.825-0.988 0.563-1.508 1.108-1.688 1.729-0.246 0.846 0.229 1.542 0.646 2.154 0.325 0.475 0.633 0.925 0.633 1.412 0 0.9 0.563 1.633 1.471 1.912 0.246 0.075 0.521 0.117 0.808 0.117 0.958 0 2.079-0.446 2.875-1.554 0.087-0.063 0.171-0.108 0.246-0.146 0.817 1.713 1.271 3.633 1.271 5.662 0 7.35-5.983 13.333-13.333 13.333z"
/>
</svg>

View File

@@ -1,19 +1,15 @@
<template>
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
xmlns="http://www.w3.org/2000/svg"
d="M24 13.333v-2.667c0-2.137-0.833-4.146-2.342-5.658s-3.521-2.342-5.658-2.342-4.146 0.833-5.658 2.342-2.342 3.521-2.342 5.658v2.667c-1.471 0-2.667 1.196-2.667 2.667v10.667c0 1.471 1.196 2.667 2.667 2.667h16c1.471 0 2.667-1.196 2.667-2.667v-10.667c0-1.471-1.196-2.667-2.667-2.667zM10.667 10.667c0-2.942 2.392-5.333 5.333-5.333s5.333 2.392 5.333 5.333v2.667h-10.667v-2.667zM24 26.667h-16v-10.667h16v10.667c0.004 0 0 0 0 0z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
d="M12 20c-0.733 0-1.333 0.6-1.333 1.333s0.6 1.333 1.333 1.333 1.333-0.6 1.333-1.333-0.6-1.333-1.333-1.333zM12 21.333c0 0 0 0 0 0v0z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
d="M16 20c-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.333zM16 21.333c0 0 0 0 0 0v0z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
d="M20 20c-0.733 0-1.333 0.6-1.333 1.333s0.6 1.333 1.333 1.333 1.333-0.6 1.333-1.333-0.6-1.333-1.333-1.333zM20 21.333c0 0 0 0 0 0v0z"
/>
</svg>

View File

@@ -1,7 +1,6 @@
<template>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
d="M31.608 13.725l-4-4c-0.304-0.304-0.729-0.442-1.154-0.375-0.421 0.067-0.788 0.333-0.979 0.713-2.938 5.804-5.517 9.617-8.354 12.358-0.004 0.004-0.012 0.012-0.017 0.017-1.008 1.008-2.346 1.563-3.771 1.563s-2.762-0.554-3.771-1.563c-1.008-1.008-1.563-2.346-1.563-3.771s0.554-2.762 1.563-3.771c0.004-0.004 0.012-0.012 0.017-0.017 2.742-2.842 6.55-5.417 12.358-8.354 0.383-0.192 0.646-0.558 0.712-0.979s-0.071-0.85-0.375-1.154l-4-4c-0.404-0.404-1.021-0.504-1.538-0.25-2.271 1.125-4.475 2.438-6.554 3.9-2.175 1.529-4.279 3.271-6.258 5.179-0.004 0.004-0.013 0.012-0.017 0.017-2.521 2.521-3.908 5.867-3.908 9.429s1.387 6.908 3.904 9.429c2.521 2.517 5.867 3.904 9.429 3.904s6.908-1.387 9.429-3.904c0.004-0.004 0.012-0.012 0.017-0.017 1.908-1.979 3.65-4.088 5.179-6.258 1.462-2.079 2.775-4.283 3.904-6.558 0.254-0.517 0.15-1.133-0.254-1.537zM17.075 2.962l2.025 2.025c-1.188 0.629-2.292 1.246-3.317 1.854l-2-2c1.071-0.667 2.167-1.296 3.292-1.879zM20.867 26.217c-2.012 2.008-4.688 3.117-7.533 3.117-2.85 0-5.529-1.108-7.542-3.125-4.154-4.154-4.158-10.917-0.008-15.075 2.183-2.104 4.454-3.942 6.858-5.55l1.971 1.971c-2.879 1.804-5.117 3.571-6.946 5.463-1.504 1.508-2.333 3.517-2.333 5.65 0 2.137 0.833 4.146 2.342 5.658 1.512 1.508 3.521 2.342 5.658 2.342 2.133 0 4.138-0.829 5.65-2.333 1.892-1.829 3.663-4.067 5.463-6.946l1.971 1.971c-1.608 2.404-3.446 4.675-5.55 6.858zM27.158 18.212l-1.996-1.996c0.608-1.025 1.225-2.129 1.854-3.317l2.025 2.025c-0.587 1.125-1.217 2.221-1.883 3.288z"
/>
</svg>

View File

@@ -1,7 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
d="M32 10.667c0-3.675-2.992-6.667-6.667-6.667-1.050 0-2.1 0.25-3.029 0.725-0.271 0.138-0.533 0.296-0.783 0.471-0.333-0.546-0.729-1.058-1.196-1.521-1.512-1.508-3.521-2.342-5.658-2.342s-4.146 0.833-5.658 2.342-2.342 3.521-2.342 5.658c0 1.262 0.3 2.517 0.871 3.633 0.446 0.875 1.062 1.671 1.796 2.329v1.35l-7.475-3.204c-0.413-0.175-0.883-0.133-1.258 0.113s-0.6 0.662-0.6 1.113v14.667c0 0.475 0.254 0.917 0.662 1.154 0.208 0.121 0.438 0.179 0.671 0.179 0.229 0 0.458-0.058 0.662-0.175l7.338-4.196v1.704c0 1.471 1.196 2.667 2.667 2.667h17.333c1.471 0 2.667-1.196 2.667-2.667v-13.333c0-0.567-0.175-1.088-0.479-1.521 0.317-0.783 0.479-1.625 0.479-2.479zM29.333 25.333h-17.333v-10.667h17.333v10.667zM25.333 6.667c2.204 0 4 1.796 4 4 0 0.458-0.079 0.908-0.229 1.333h-6.892c0.3-0.846 0.454-1.746 0.454-2.667 0-0.517-0.050-1.021-0.142-1.517 0.746-0.737 1.742-1.15 2.808-1.15zM14.667 4c2.942 0 5.333 2.392 5.333 5.333 0 0.95-0.246 1.863-0.712 2.667h-7.287c-0.6 0-1.154 0.2-1.6 0.537-0.688-0.912-1.067-2.025-1.067-3.204 0-2.942 2.392-5.333 5.333-5.333zM2.667 27.038v-10.35l6.667 2.858v3.679l-6.667 3.813zM12 28.004c0 0 0-0.004 0 0v-1.337h17.333v1.333l-17.333 0.004z"
/>
</svg>

View File

@@ -1,19 +1,17 @@
<template>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
d="M31.608 12.392l-3.642-3.642c-0.521-0.521-1.367-0.521-1.887 0-0.375 0.375-0.879 0.583-1.413 0.583s-1.038-0.208-1.413-0.588c-0.779-0.779-0.779-2.050 0-2.829 0.521-0.521 0.521-1.367 0-1.888l-3.646-3.638c-0.25-0.25-0.587-0.392-0.942-0.392s-0.692 0.142-0.942 0.392l-17.333 17.333c-0.521 0.521-0.521 1.367 0 1.887l3.642 3.642c0.25 0.25 0.588 0.392 0.942 0.392s0.692-0.142 0.942-0.392c0.379-0.379 0.879-0.587 1.412-0.587s1.037 0.208 1.412 0.587 0.592 0.879 0.592 1.413c0 0.533-0.208 1.038-0.588 1.413-0.25 0.25-0.392 0.587-0.392 0.942s0.142 0.692 0.392 0.942l3.642 3.642c0.258 0.258 0.6 0.392 0.942 0.392s0.683-0.129 0.942-0.392l17.333-17.333c0.525-0.517 0.525-1.358 0.004-1.879zM13.333 28.779l-1.896-1.896c0.954-1.767 0.688-4.029-0.804-5.521-0.883-0.875-2.054-1.363-3.3-1.363 0 0 0 0 0 0-0.787 0-1.546 0.196-2.221 0.558l-1.892-1.892 15.446-15.446 1.896 1.896c-0.954 1.767-0.688 4.029 0.804 5.521 0.908 0.908 2.104 1.367 3.3 1.367 0.767 0 1.529-0.188 2.221-0.558l1.896 1.896-15.45 15.438z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
d="M17.137 8.196c-0.258-0.258-0.683-0.258-0.942 0l-8 8c-0.258 0.258-0.258 0.683 0 0.942l6.667 6.667c0.129 0.129 0.3 0.196 0.471 0.196s0.342-0.067 0.471-0.196l8-8c0.258-0.258 0.258-0.683 0-0.942l-6.667-6.667zM15.333 22.392l-5.725-5.725 7.058-7.058 5.725 5.725-7.058 7.058z"
/>
</svg>
<!-- <svg
xmlns="http://www.w3.org/2000/svg"
version="1.1" xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"

View File

@@ -1,11 +1,9 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
d="M16 18.667c4.413 0 8-3.588 8-8s-3.587-8-8-8-8 3.588-8 8 3.588 8 8 8zM16 5.333c2.942 0 5.333 2.392 5.333 5.333s-2.392 5.333-5.333 5.333c-2.942 0-5.333-2.392-5.333-5.333s2.392-5.333 5.333-5.333z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
d="M26.279 22.758c-1.842-1.858-5.204-2.758-10.279-2.758s-8.438 0.9-10.279 2.758c-1.721 1.733-1.721 3.846-1.721 5.242v0.667c0 0.367 0.3 0.667 0.667 0.667h22.667c0.367 0 0.667-0.3 0.667-0.667v-0.667c0-1.396 0-3.508-1.721-5.242zM6.667 28c0-1.183 0-2.413 0.946-3.363 0.563-0.567 1.429-1.017 2.583-1.342 1.475-0.417 3.429-0.629 5.804-0.629s4.329 0.212 5.804 0.629c1.154 0.325 2.021 0.775 2.583 1.342 0.946 0.95 0.946 2.179 0.946 3.363h-18.667z"
/>
</svg>

View File

@@ -1,11 +1,9 @@
<template>
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path
xmlns="http://www.w3.org/2000/svg"
d="M31.333 4h-30.667c-0.367 0-0.667 0.3-0.667 0.667v20c0 0.367 0.3 0.667 0.667 0.667h16.417c1.579 1.642 3.796 2.667 6.25 2.667 4.779 0 8.667-3.887 8.667-8.667v-14.667c0-0.367-0.3-0.667-0.667-0.667zM5.333 5.333v2.667h-2.667v-2.667h2.667zM2.667 17.333h2.667v2.667h-2.667v-2.667zM2.667 16v-2.667h2.667v2.667h-2.667zM2.667 12v-2.667h2.667v2.667h-2.667zM2.667 24v-2.667h2.667v2.667h-2.667zM29.333 12h-1.387c-0.404-0.254-0.833-0.479-1.279-0.667v-2h2.667v2.667zM8 6.667h16v4.025c-0.221-0.017-0.442-0.025-0.667-0.025-4.779 0-8.667 3.888-8.667 8.667 0 1.179 0.238 2.308 0.667 3.333h-7.333v-16zM23.333 26.667c-4.042 0-7.333-3.292-7.333-7.333s3.292-7.333 7.333-7.333 7.333 3.292 7.333 7.333-3.292 7.333-7.333 7.333zM29.333 8h-2.667v-2.667h2.667v2.667z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
d="M27.675 18.762l-6.667-4c-0.204-0.125-0.462-0.125-0.671-0.008s-0.337 0.342-0.337 0.579v8c0 0.242 0.129 0.462 0.337 0.579 0.1 0.058 0.217 0.087 0.329 0.087 0.121 0 0.238-0.033 0.342-0.096l6.667-4c0.2-0.121 0.325-0.337 0.325-0.571s-0.121-0.45-0.325-0.571zM21.333 22.154v-5.642l4.704 2.821-4.704 2.821z"
/>
</svg>

View File

@@ -1,27 +1,22 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
d="M21.333 20h-2.667c-0.738 0-1.333 0.596-1.333 1.333v10c0 0.367 0.3 0.667 0.667 0.667h4c0.367 0 0.667-0.3 0.667-0.667v-10c0-0.738-0.596-1.333-1.333-1.333zM18.667 30.667v-8h1.333v8h-1.333z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
d="M13.333 14.667h-2.667c-0.738 0-1.333 0.596-1.333 1.333v15.333c0 0.367 0.3 0.667 0.667 0.667h4c0.367 0 0.667-0.3 0.667-0.667v-15.333c0-0.738-0.596-1.333-1.333-1.333zM10.667 30.667v-13.333h1.333v13.333h-1.333z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
d="M5.333 20h-2.667c-0.738 0-1.333 0.596-1.333 1.333v10c0 0.367 0.3 0.667 0.667 0.667h4c0.367 0 0.667-0.3 0.667-0.667v-10c0-0.738-0.596-1.333-1.333-1.333zM2.667 30.667v-8h1.333v8h-1.333z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
d="M29.333 9.333h-2.667c-0.738 0-1.333 0.596-1.333 1.333v20.667c0 0.367 0.3 0.667 0.667 0.667h4c0.367 0 0.667-0.3 0.667-0.667v-20.667c0-0.738-0.596-1.333-1.333-1.333zM26.667 30.667v-18.667h1.333v18.667h-1.333z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
d="M31.333 0h-3.333v1.333h1.592l-11.738 9.267-9.004-2.571c-0.208-0.058-0.429-0.012-0.6 0.121l-7.188 5.75 0.833 1.042 6.917-5.533 9.004 2.571c0.204 0.058 0.429 0.017 0.596-0.117l12.254-9.679v1.817h1.333v-3.333c0-0.367-0.3-0.667-0.667-0.667z"
/>

View File

@@ -1,12 +1,10 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
d="M16 18.667c4.413 0 8-3.588 8-8s-3.587-8-8-8-8 3.588-8 8 3.588 8 8 8zM16 5.333c2.942 0 5.333 2.392 5.333 5.333s-2.392 5.333-5.333 5.333c-2.942 0-5.333-2.392-5.333-5.333s2.392-5.333 5.333-5.333z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
d="M26.279 22.758c-1.842-1.858-5.204-2.758-10.279-2.758s-8.438 0.9-10.279 2.758c-1.721 1.733-1.721 3.846-1.721 5.242v0.667c0 0.367 0.3 0.667 0.667 0.667h22.667c0.367 0 0.667-0.3 0.667-0.667v-0.667c0-1.396 0-3.508-1.721-5.242zM6.667 28c0-1.183 0-2.413 0.946-3.363 0.563-0.567 1.429-1.017 2.583-1.342 1.475-0.417 3.429-0.629 5.804-0.629s4.329 0.212 5.804 0.629c1.154 0.325 2.021 0.775 2.583 1.342 0.946 0.95 0.946 2.179 0.946 3.363h-18.667z"
/>

View File

@@ -1,15 +1,12 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
d="M12 16c4.413 0 8-3.588 8-8s-3.587-8-8-8-8 3.587-8 8 3.588 8 8 8zM12 2.667c2.942 0 5.333 2.392 5.333 5.333s-2.392 5.333-5.333 5.333-5.333-2.392-5.333-5.333 2.392-5.333 5.333-5.333z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
d="M23.333 14.667c-2.617 0-4.971 1.167-6.558 3.008-1.367-0.225-2.975-0.342-4.775-0.342-5.075 0-8.438 0.9-10.279 2.758-1.721 1.733-1.721 3.846-1.721 5.242v0.667c0 0.367 0.3 0.667 0.667 0.667h14.667c1.308 3.129 4.4 5.333 8 5.333 4.779 0 8.667-3.887 8.667-8.667s-3.887-8.667-8.667-8.667zM2.667 25.333c0-1.183 0-2.413 0.946-3.363 0.563-0.567 1.429-1.017 2.583-1.342 1.475-0.417 3.429-0.629 5.804-0.629 1.196 0 2.292 0.054 3.267 0.163-0.387 0.983-0.6 2.054-0.6 3.171 0 0.688 0.079 1.358 0.233 2h-12.233zM23.333 30.667c-4.042 0-7.333-3.292-7.333-7.333s3.292-7.333 7.333-7.333 7.333 3.292 7.333 7.333-3.292 7.333-7.333 7.333z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
d="M27.333 22.667h-0.667v-0.667c0-1.837-1.496-3.333-3.333-3.333s-3.333 1.496-3.333 3.333v0.667h-0.667c-0.367 0-0.667 0.3-0.667 0.667v4c0 0.367 0.3 0.667 0.667 0.667h8c0.367 0 0.667-0.3 0.667-0.667v-4c0-0.367-0.3-0.667-0.667-0.667zM21.333 22c0-1.104 0.896-2 2-2s2 0.896 2 2v0.667h-4v-0.667zM26.667 26.667h-6.667v-2.667h6.667v2.667z"
/>
</svg>

View File

@@ -1,16 +1,14 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
d="M23.988 13.863c0 0 0 0 0 0 0-0.004 0-0.008-0.004-0.012 0 0 0 0 0-0.004s0-0.004 0-0.008c0 0 0 0 0 0 0-0.004 0-0.004-0.004-0.008 0 0 0-0.004 0-0.004 0-0.004 0-0.004 0-0.008 0 0 0-0.004 0-0.004s0-0.004 0-0.004c0 0 0-0.004 0-0.004 0-0.004 0-0.004-0.004-0.008 0 0 0-0.004 0-0.004s0-0.004 0-0.004c0 0 0-0.004 0-0.004 0-0.004 0-0.004-0.004-0.008 0 0 0 0 0-0.004s0-0.004-0.004-0.008c0 0 0 0 0-0.004s-0.004-0.004-0.004-0.008c0 0 0 0 0 0-0.050-0.121-0.133-0.229-0.246-0.304 0 0 0 0 0 0-0.004-0.004-0.012-0.008-0.017-0.012 0 0 0 0 0 0-0.004 0-0.004-0.004-0.008-0.004 0 0 0 0 0 0-0.004 0-0.004-0.004-0.008-0.004 0 0 0 0-0.004 0 0 0 0 0-0.004 0-0.004-0.004-0.012-0.008-0.017-0.008 0 0 0 0 0 0-0.133-0.071-0.279-0.092-0.421-0.071 0 0 0 0 0 0-0.004 0-0.008 0-0.008 0s0 0 0 0c-0.004 0-0.004 0-0.008 0 0 0 0 0-0.004 0s-0.008 0-0.008 0c0 0 0 0 0 0-0.004 0-0.004 0-0.008 0 0 0 0 0-0.004 0s-0.008 0-0.008 0.004c0 0 0 0-0.004 0s-0.004 0-0.008 0.004c0 0 0 0 0 0-0.004 0-0.008 0-0.008 0.004 0 0 0 0 0 0-0.008 0-0.012 0.004-0.021 0.008 0 0 0 0 0 0-0.067 0.021-0.133 0.058-0.192 0.1l-14.679 10.662c-0.208 0.154-0.313 0.413-0.262 0.667s0.242 0.458 0.492 0.521l4.946 1.238 1.238 4.946c0.067 0.262 0.287 0.462 0.554 0.5 0.029 0.004 0.063 0.008 0.092 0.008 0.238 0 0.458-0.125 0.579-0.337l2.383-4.175 4.804 1.796c0.204 0.075 0.433 0.050 0.613-0.075s0.288-0.329 0.288-0.55v-14.654c0-0.050-0.004-0.1-0.012-0.15zM10.213 24.367l9.7-7.054-6.171 7.933-3.529-0.879zM15.579 29.563l-0.854-3.408 6.033-7.758-3.358 7.975-1.821 3.192zM18.883 26.288l3.783-8.988v10.404l-3.783-1.417z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
d="M28.488 3.513c-2.267-2.263-5.283-3.513-8.488-3.513-2.5 0-4.9 0.762-6.933 2.204-1.667 1.179-2.983 2.737-3.863 4.554-0.396-0.058-0.796-0.092-1.204-0.092-2.138 0-4.146 0.833-5.658 2.342s-2.342 3.521-2.342 5.658c0 1.858 0.65 3.667 1.829 5.096 1.163 1.408 2.788 2.383 4.571 2.746l0.529-2.613c-2.471-0.504-4.263-2.7-4.263-5.229 0-2.942 2.392-5.333 5.333-5.333 1.8 0 3.462 0.896 4.454 2.4l2.225-1.467c-0.75-1.137-1.754-2.042-2.917-2.662 1.608-2.996 4.775-4.938 8.238-4.938 5.146 0 9.333 4.188 9.333 9.333 0 2.225-0.938 4.367-2.571 5.875l1.808 1.958c1.071-0.988 1.913-2.163 2.504-3.488 0.613-1.371 0.925-2.833 0.925-4.35 0-3.2-1.25-6.217-3.512-8.483z"
/>
<!-- <path
xmlns="http://www.w3.org/2000/svg"
version="1.1" xmlns="http://www.w3.org/2000/svg"
d="M31.542 1.658c-0.396-0.342-0.95-0.425-1.425-0.208l-29.333 13.333c-0.512 0.233-0.821 0.758-0.779 1.317s0.425 1.029 0.963 1.183l8.367 2.387v9.663c0 0.587 0.383 1.104 0.946 1.275 0.129 0.038 0.258 0.058 0.387 0.058 0.438 0 0.858-0.217 1.108-0.596l4.683-7.017 6.946 3.475c0.354 0.175 0.767 0.188 1.129 0.029s0.637-0.467 0.746-0.846l6.667-22.667c0.146-0.504-0.012-1.046-0.404-1.387zM5.183 15.713l18.771-8.533-12.804 10.246c-0.037-0.017-0.079-0.029-0.117-0.042l-5.85-1.671zM12 24.929v-6.262c0-0.067-0.004-0.133-0.017-0.2l13.963-11.171-10.971 13.183c-0.029 0.038-0.058 0.075-0.083 0.113l-2.892 4.338zM23.171 23.429l-6.296-3.15 11.167-13.421-4.871 16.571z"
/> -->
</svg>

View File

@@ -1,16 +1,14 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
xmlns="http://www.w3.org/2000/svg"
d="M31.904 16.087c0.063-0.471 0.096-0.946 0.096-1.421 0-3.204-1.25-6.221-3.512-8.488-2.267-2.263-5.283-3.513-8.488-3.513-2.5 0-4.9 0.763-6.933 2.204-1.667 1.179-2.983 2.737-3.863 4.554-0.396-0.058-0.796-0.092-1.204-0.092-2.138 0-4.146 0.833-5.658 2.342s-2.342 3.521-2.342 5.658 0.833 4.146 2.342 5.658c1.513 1.508 3.521 2.342 5.658 2.342h8.033c1.542 2.404 4.238 4 7.3 4 4.779 0 8.667-3.887 8.667-8.667 0-0.992-0.167-1.946-0.475-2.833 0.175-0.567 0.304-1.154 0.379-1.746zM8 22.667c-2.942 0-5.333-2.392-5.333-5.333s2.392-5.333 5.333-5.333c1.8 0 3.463 0.896 4.454 2.4l2.225-1.467c-0.75-1.137-1.754-2.042-2.917-2.662 1.608-2.996 4.775-4.938 8.238-4.938 5.063 0 9.196 4.050 9.329 9.083-1.558-1.496-3.671-2.417-5.996-2.417-4.779 0-8.667 3.887-8.667 8.667 0 0.688 0.079 1.358 0.233 2h-6.9zM23.333 28c-4.042 0-7.333-3.292-7.333-7.333s3.292-7.333 7.333-7.333 7.333 3.292 7.333 7.333-3.292 7.333-7.333 7.333z"
/>
<path
xmlns="http://www.w3.org/2000/svg"
d="M22 22.392l-2.667-2.667-0.942 0.942 3.137 3.137c0.129 0.129 0.3 0.196 0.471 0.196s0.342-0.067 0.471-0.196l5.804-5.804-0.942-0.942-5.333 5.333z"
/>
<!-- <path
xmlns="http://www.w3.org/2000/svg"
version="1.1" xmlns="http://www.w3.org/2000/svg"
d="M31.542 1.658c-0.396-0.342-0.95-0.425-1.425-0.208l-29.333 13.333c-0.512 0.233-0.821 0.758-0.779 1.317s0.425 1.029 0.963 1.183l8.367 2.387v9.663c0 0.587 0.383 1.104 0.946 1.275 0.129 0.038 0.258 0.058 0.387 0.058 0.438 0 0.858-0.217 1.108-0.596l4.683-7.017 6.946 3.475c0.354 0.175 0.767 0.188 1.129 0.029s0.637-0.467 0.746-0.846l6.667-22.667c0.146-0.504-0.012-1.046-0.404-1.387zM5.183 15.713l18.771-8.533-12.804 10.246c-0.037-0.017-0.079-0.029-0.117-0.042l-5.85-1.671zM12 24.929v-6.262c0-0.067-0.004-0.133-0.017-0.2l13.963-11.171-10.971 13.183c-0.029 0.038-0.058 0.075-0.083 0.113l-2.892 4.338zM23.171 23.429l-6.296-3.15 11.167-13.421-4.871 16.571z"
/> -->
</svg>

View File

@@ -1,10 +1,12 @@
<template>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
style="transition-duration: 0s;"
style="transition-duration: 0s"
>
<path
version="1.1"
xmlns="http://www.w3.org/2000/svg"
fill="inherit"
d="M21.388 21.141c-0.045 0.035-0.089 0.073-0.132 0.116s-0.080 0.085-0.116 0.132c-1.677 1.617-3.959 2.611-6.473 2.611-2.577 0-4.909-1.043-6.6-2.733s-2.733-4.023-2.733-6.6 1.043-4.909 2.733-6.6 4.023-2.733 6.6-2.733 4.909 1.043 6.6 2.733 2.733 4.023 2.733 6.6c0 2.515-0.993 4.796-2.612 6.475zM28.943 27.057l-4.9-4.9c1.641-2.053 2.624-4.657 2.624-7.491 0-3.313-1.344-6.315-3.515-8.485s-5.172-3.515-8.485-3.515-6.315 1.344-8.485 3.515-3.515 5.172-3.515 8.485 1.344 6.315 3.515 8.485 5.172 3.515 8.485 3.515c2.833 0 5.437-0.983 7.491-2.624l4.9 4.9c0.521 0.521 1.365 0.521 1.885 0s0.521-1.365 0-1.885z"

View File

@@ -1,5 +1,6 @@
<template>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
@@ -9,7 +10,6 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="transition: stroke-width 0.5s ease"
>
<line x1="4" y1="21" x2="4" y2="14"></line>
<line x1="4" y1="10" x2="4" y2="3"></line>

View File

@@ -1,5 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
d="M29.333 6.667h-26.667c-1.471 0-2.667 1.196-2.667 2.667v18.667c0 1.471 1.196 2.667 2.667 2.667h26.667c1.471 0 2.667-1.196 2.667-2.667v-18.667c0-1.471-1.196-2.667-2.667-2.667zM29.333 28h-26.667v-18.667h26.667v18.667z"
></path>

17
src/icons/IconStop.vue Normal file
View File

@@ -0,0 +1,17 @@
<template>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="768"
height="768"
viewBox="0 0 768 768"
fill="currentColor"
>
<path
d="M758.6 215.6l-206.2-206.2c-6-6-14.1-9.4-22.6-9.4h-291.6c-8.5 0-16.6 3.4-22.6 9.4l-206.2 206.2c-6 6-9.4 14.1-9.4 22.6v291.6c0 8.5 3.4 16.6 9.4 22.6l206.2 206.2c6 6 14.1 9.4 22.6 9.4h291.6c8.5 0 16.6-3.4 22.6-9.4l206.2-206.2c6-6 9.4-14.1 9.4-22.6v-291.6c0-8.5-3.4-16.6-9.4-22.6zM704 516.5l-187.5 187.5h-265l-187.5-187.5v-265l187.5-187.5h265.1l187.4 187.5v265z"
></path>
<path
d="M512 240.7c0-13.1-5.4-25.8-14.8-35-9.3-9.1-21.5-14-34.3-13.7-5.8 0.1-11.4 1.3-16.7 3.3-5.5-20.3-24.1-35.3-46.2-35.3s-40.8 15-46.3 35.4c-5.5-2.2-11.4-3.4-17.7-3.4-26.5 0-48 21.5-48 48v18.7c-4.8-1.7-9.8-2.6-15-2.7-12.8-0.3-25 4.6-34.3 13.7-9.4 9.2-14.8 21.9-14.8 35v111.3c0 56.6 10.1 104.3 29.1 137.9 19.8 34.9 49.2 54.1 82.9 54.1h106.6c38.9 0 74.2-21.4 92.2-55.9l67.5-129.4c0.1-0.2 0.2-0.3 0.3-0.5 4-7.7 5.8-16.3 5.3-25-1.4-24-20.5-43.4-44.5-45-16.3-1.1-32 6-41.8 19.1l-9.5 12.7v-143.3zM547.2 390.4c3.3-4.4 8.5-6.7 14-6.4 7.8 0.5 14.3 7.1 14.8 15 0.2 2.8-0.4 5.5-1.6 8-0.1 0.1-0.1 0.2-0.2 0.3l-67.7 129.9c-12.5 24-36.9 38.8-63.9 38.8h-106.6c-27.8 0-45.3-20.6-55.1-37.9-16.3-28.8-24.9-71-24.9-122.1v-111.3c0-4.5 1.9-8.9 5.1-12.2 3.1-3 7.1-4.7 11.2-4.6 8.8 0.2 15.6 7.2 15.6 16v112h32v-175.9c0-8.8 7.2-16 16-16s16 7.2 16 16v144h32v-176c0-8.8 7.2-16 16-16s16 7.2 16 16v176h32v-144c0-8.8 6.9-15.8 15.6-16 4.2-0.1 8.1 1.5 11.2 4.6 3.3 3.2 5.1 7.6 5.1 12.2v191.2c0 6.9 4.4 13 10.9 15.2s13.7-0.1 17.9-5.6l38.6-51.2z"
></path>
</svg>
</template>

View File

@@ -1,6 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
version="1.1"
xmlns="http://www.w3.org/2000/svg"
d="M28 13.333c0-0.708-0.188-1.4-0.538-2 0.346-0.6 0.538-1.292 0.538-2 0-1.208-0.546-2.329-1.442-3.075 0.071-0.3 0.108-0.612 0.108-0.925 0-2.204-1.796-4-4-4h-3.621c-3.842 0-7.683 0.946-11.104 2.729 0 0-0.004 0-0.004 0.004-2.429 1.279-3.938 3.792-3.938 6.563v4.671c0 1.137 0.263 2.275 0.763 3.292 0.504 1.025 1.246 1.925 2.146 2.613 1.237 0.942 2.417 1.542 3.554 2.117 2.175 1.104 3.896 1.979 5.412 4.962 0.313 0.783 0.817 1.417 1.475 1.846 0.708 0.462 1.529 0.633 2.321 0.483 0.921-0.175 1.717-0.767 2.246-1.671 0.508-0.871 0.758-2.004 0.742-3.358 0-1.283-0.296-2.863-1.029-4.25h2.375c2.204 0 4-1.796 4-4 0-0.708-0.188-1.4-0.538-2 0.346-0.6 0.533-1.292 0.533-2zM22.667 6.667h-2.667v1.333h4c0.208 0 0.404 0.046 0.587 0.137 0.454 0.221 0.746 0.683 0.746 1.196 0 0.329-0.121 0.646-0.337 0.887-0.254 0.283-0.613 0.442-0.992 0.446 0 0-0.004 0-0.004 0h-2.667v1.333h2.667c0 0 0.004 0 0.004 0 0.325 0 0.637 0.121 0.879 0.333 0.288 0.254 0.45 0.617 0.45 1s-0.163 0.746-0.45 1c-0.242 0.213-0.554 0.333-0.883 0.333h-2.667v1.333h2.667c0.379 0 0.742 0.163 0.996 0.446 0.217 0.242 0.337 0.558 0.337 0.887 0 0.733-0.6 1.333-1.333 1.333h-5.333c-0.6 0-1.125 0.4-1.283 0.979s0.083 1.192 0.6 1.5c1.379 0.829 2.008 2.887 2.008 4.45 0 0.004 0 0.012 0 0.017 0.021 1.633-0.475 2.321-0.813 2.387-0.254 0.046-0.629-0.188-0.833-0.725-0.017-0.046-0.033-0.087-0.058-0.129-1.917-3.813-4.304-5.025-6.617-6.2-1.033-0.525-2.1-1.067-3.146-1.863-1.162-0.883-1.858-2.296-1.858-3.779v-4.675c0-1.775 0.963-3.388 2.508-4.2 3.042-1.587 6.454-2.429 9.871-2.429h3.621c0.733 0 1.333 0.6 1.333 1.333 0 0.229-0.058 0.45-0.163 0.646-0.238 0.425-0.688 0.688-1.171 0.688z"
/>

View File

@@ -1,6 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path
version="1.1"
xmlns="http://www.w3.org/2000/svg"
d="M27.462 16.667c0.346-0.6 0.538-1.292 0.538-2 0-2.204-1.796-4-4-4h-2.375c0.733-1.387 1.029-2.967 1.029-4.25 0.017-1.358-0.233-2.487-0.742-3.358-0.525-0.904-1.321-1.5-2.246-1.671-0.788-0.15-1.613 0.025-2.321 0.483-0.654 0.425-1.163 1.063-1.475 1.846-1.517 2.983-3.237 3.858-5.412 4.962-1.137 0.579-2.313 1.175-3.554 2.117-0.904 0.688-1.646 1.588-2.146 2.612-0.5 1.017-0.763 2.154-0.763 3.292v4.671c0 2.771 1.508 5.283 3.938 6.563 0 0 0.004 0 0.004 0.004 3.421 1.788 7.263 2.729 11.104 2.729h3.625c2.204 0 4-1.796 4-4 0-0.317-0.038-0.625-0.108-0.925 0.896-0.746 1.442-1.867 1.442-3.075 0-0.708-0.188-1.4-0.538-2 0.346-0.6 0.538-1.292 0.538-2s-0.188-1.4-0.538-2zM23.837 26.021c0.108 0.196 0.163 0.417 0.163 0.646 0 0.733-0.6 1.333-1.333 1.333h-3.621c-3.412 0-6.825-0.837-9.871-2.429-1.55-0.817-2.508-2.425-2.508-4.2v-4.671c0-1.483 0.696-2.896 1.858-3.779 1.050-0.796 2.117-1.338 3.146-1.863 2.308-1.175 4.696-2.387 6.617-6.2 0.021-0.042 0.042-0.083 0.058-0.129 0.2-0.533 0.579-0.771 0.833-0.725 0.337 0.063 0.833 0.75 0.813 2.388 0 0.004 0 0.013 0 0.017 0 1.563-0.629 3.621-2.008 4.45-0.512 0.308-0.758 0.921-0.6 1.5 0.158 0.575 0.683 0.975 1.283 0.975h5.333c0.733 0 1.333 0.6 1.333 1.333 0 0.329-0.121 0.646-0.337 0.887-0.254 0.283-0.617 0.446-0.996 0.446h-2.667v1.333h2.667c0.325 0 0.642 0.121 0.883 0.333 0.288 0.254 0.45 0.617 0.45 1s-0.163 0.746-0.45 1c-0.242 0.212-0.554 0.333-0.879 0.333 0 0-0.004 0-0.004 0v0h-2.667v1.333h2.667c0 0 0.004 0 0.004 0 0.375 0 0.738 0.163 0.992 0.446 0.217 0.242 0.337 0.558 0.337 0.887 0 0.512-0.292 0.975-0.746 1.196-0.183 0.092-0.383 0.137-0.587 0.137h-4v1.333h2.667c0.483 0 0.933 0.262 1.171 0.688z"
/>

View File

@@ -0,0 +1,21 @@
<template>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="768"
height="768"
viewBox="0 0 768 768"
fill="currentColor"
>
<path
d="M672 608h-56.1l87.8-396.5c0.8-3.7 0.3-7.6-1.5-11l-102.2-192c-2.8-5.2-8.2-8.5-14.1-8.5h-393.9c-11.7 0-22.5 6.4-28.1 16.7l-96 176c-3.7 6.8-4.8 14.7-3.1 22.3l87.3 393h-56.1c-35.3 0-64 28.7-64 64v80c0 8.8 7.2 16 16 16h672c8.8 0 16-7.2 16-16v-80c0-35.3-28.7-64-64-64zM519.9 608l85.3-384h62.8l-85 384h-63.1zM661.4 192h-57.6l-87.3-160h59.8l85.1 160zM129.8 212.8l81.2-148.8h250l81.2 148.8-87.9 395.2h-236.6l-87.9-395.2zM96 736v-64h576v64h-576z"
></path>
<path d="M320 224h32v160h-32v-160z"></path>
<path
d="M288 272c0-26.5-21.5-48-48-48h-32c-8.8 0-16 7.2-16 16v144h32v-64h6.1l34.8 69.5 28.6-14.3-30.5-61c14.9-8.2 25-24 25-42.2zM224 256h16c8.8 0 16 7.2 16 16s-7.2 16-16 16h-16v-32z"
></path>
<path
d="M480 272c0-26.5-21.5-48-48-48h-32c-8.8 0-16 7.2-16 16v144h32v-64h16c26.5 0 48-21.5 48-48zM416 256h16c8.8 0 16 7.2 16 16s-7.2 16-16 16h-16v-32z"
></path>
</svg>
</template>

View File

@@ -0,0 +1 @@
IconBinoculars.vue

View File

@@ -0,0 +1,7 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title>accessibility</title>
<path d="M384 224c35.3 0 64-28.7 64-64s-28.7-64-64-64c-35.3 0-64 28.7-64 64s28.7 64 64 64zM384 128c17.6 0 32 14.4 32 32s-14.4 32-32 32c-17.6 0-32-14.4-32-32s14.4-32 32-32z"></path>
<path d="M586.1 254.4l-20.2-60.8-181.9 60.7-181.9-60.7-20.2 60.8 170.1 56.7v127.1l-130.5 191.8 53 36 109.5-161.1 109.5 161.1 53-36-130.5-191.8v-127.1z"></path>
<path d="M737.8 234.5c-19.3-45.7-47-86.8-82.3-122.1s-76.3-62.9-122.1-82.3c-47.3-19.9-97.6-30.1-149.4-30.1s-102.1 10.2-149.5 30.2c-45.7 19.3-86.8 47-122.1 82.3s-62.9 76.3-82.3 122.1c-19.9 47.3-30.1 97.6-30.1 149.4s10.2 102.1 30.2 149.5c19.3 45.7 47 86.8 82.3 122.1s76.3 62.9 122.1 82.3c47.4 20 97.6 30.2 149.5 30.2 51.8 0 102.1-10.2 149.5-30.2 45.7-19.3 86.8-47 122.1-82.3s62.9-76.3 82.3-122.1c20-47.4 30.2-97.6 30.2-149.5-0.2-51.8-10.4-102.1-30.4-149.5zM384 736c-194.1 0-352-157.9-352-352s157.9-352 352-352c194.1 0 352 157.9 352 352s-157.9 352-352 352z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,7 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title></title>
<g id="icomoon-ignore">
</g>
<path d="M737.8 234.5c-19.3-45.7-47-86.8-82.3-122-35.3-35.3-76.3-62.9-122-82.3-47.4-20-97.7-30.2-149.5-30.2s-102.1 10.2-149.5 30.2c-45.7 19.3-86.8 47-122 82.3-35.3 35.3-62.9 76.3-82.3 122-20 47.4-30.2 97.7-30.2 149.5s10.2 102.1 30.2 149.5c19.3 45.7 47 86.8 82.3 122 35.3 35.3 76.3 62.9 122 82.3 47.4 20 97.7 30.2 149.5 30.2h160v-64h-160c-176.4 0-320-143.6-320-320s143.6-320 320-320c176.4 0 320 143.6 320 320v16c0 44.1-35.9 80-80 80s-80-35.9-80-80v-176h-64v32.1c-26.8-20.1-60-32.1-96-32.1-88.2 0-160 71.8-160 160s71.8 160 160 160c49.9 0 94.5-23 123.9-58.9 26.2 35.7 68.5 58.9 116.1 58.9 79.4 0 144-64.6 144-144v-16c0-51.8-10.2-102.1-30.2-149.5zM384 480c-52.9 0-96-43.1-96-96s43.1-96 96-96 96 43.1 96 96-43.1 96-96 96z"></path>
</svg>

After

Width:  |  Height:  |  Size: 913 B

View File

@@ -0,0 +1,9 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title></title>
<g id="icomoon-ignore">
</g>
<path d="M751.5 453.5v0l-121.7-260.3c-0.1-0.2-0.2-0.4-0.3-0.7-13-27.7-36.7-48.5-64.7-58.3l-27.1-46.1c-0.1-0.1-0.2-0.3-0.3-0.4-8.6-14.6-24.5-23.7-41.4-23.7-26.5 0-48 21.5-48 48v49.7c-19.8 20.2-32 47.9-32 78.3v24.6c-9.4-5.5-20.4-8.6-32-8.6s-22.6 3.1-32 8.6v-24.6c0-30.4-12.2-58.1-32-78.3v-49.7c0-26.5-21.5-48-48-48-16.9 0-32.8 9.1-41.4 23.7-0.1 0.1-0.2 0.3-0.3 0.4l-27.1 46.1c-28.1 9.8-51.8 30.6-64.7 58.3-0.1 0.2-0.2 0.4-0.3 0.6l-121.7 260.4c-10.6 22.7-16.5 47.9-16.5 74.5 0 97 79 176 176 176 94.6 0 172-75 175.8-168.7 9.5 5.5 20.4 8.7 32.2 8.7 11.7 0 22.7-3.2 32.2-8.7 3.8 93.7 81.2 168.7 175.8 168.7 97 0 176-79 176-176 0-26.6-5.9-51.8-16.5-74.5zM636 357.6c-14.1-3.6-28.8-5.6-44-5.6-42.5 0-81.5 15.1-112 40.3v-152.3c0-26.5 21.5-48 48-48 18.4 0 35.3 10.7 43.3 27.2 0.1 0.2 0.2 0.3 0.2 0.5l64.5 137.9zM496 96c5.6 0 10.7 2.8 13.6 7.6 0.1 0.1 0.1 0.2 0.2 0.3l14.2 24.2c-15.7 0.6-30.6 4.4-44 10.7v-26.8c0-8.8 7.2-16 16-16zM384 288c17.6 0 32 14.4 32 32v104.6c-9.4-5.5-20.4-8.6-32-8.6s-22.6 3.1-32 8.6v-104.6c0-17.6 14.4-32 32-32zM258.4 103.6c2.9-4.8 8-7.6 13.6-7.6 8.8 0 16 7.2 16 16v26.8c-13.4-6.4-28.3-10.2-44-10.7l14.2-24.2c0.1-0.1 0.1-0.2 0.2-0.3zM196.5 219.7c0.1-0.2 0.1-0.3 0.2-0.5 8-16.5 24.9-27.2 43.3-27.2 26.5 0 48 21.5 48 48v152.3c-30.5-25.2-69.5-40.3-112-40.3-15.2 0-29.9 1.9-44 5.6l64.5-137.9zM176 640c-61.8 0-112-50.2-112-112s50.2-112 112-112 112 50.2 112 112-50.2 112-112 112zM384 512c-17.6 0-32-14.4-32-32s14.4-32 32-32c17.6 0 32 14.4 32 32s-14.4 32-32 32zM592 640c-61.8 0-112-50.2-112-112s50.2-112 112-112 112 50.2 112 112-50.2 112-112 112z"></path>
<path d="M128 528v-16h-32v16c0 44.1 35.9 80 80 80h16v-32h-16c-26.5 0-48-21.5-48-48z"></path>
<path d="M544 528v-16h-32v16c0 44.1 35.9 80 80 80h16v-32h-16c-26.5 0-48-21.5-48-48z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title>bookmark</title>
<path d="M576 32h-384c-35.3 0-64 28.7-64 64v608c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l201.3-201.4 201.4 201.4c6.1 6.1 14.3 9.4 22.6 9.4 4.1 0 8.3-0.8 12.2-2.4 12-5 19.8-16.6 19.8-29.6v-608.1c0-35.3-28.7-64-64-64zM576 626.7l-169.4-169.3c-6.2-6.2-14.4-9.4-22.6-9.4s-16.4 3.1-22.6 9.4l-169.4 169.3v-530.7h384v530.7z"></path>
</svg>

After

Width:  |  Height:  |  Size: 494 B

View File

@@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title>branches2</title>
<path d="M704 517.5v-101.5c0-35.3-28.7-64-64-64h-224v-101.5c37.2-13.2 64-48.8 64-90.5 0-52.9-43.1-96-96-96s-96 43.1-96 96c0 41.7 26.8 77.3 64 90.5v101.5h-224c-35.3 0-64 28.7-64 64v101.5c-37.2 13.2-64 48.8-64 90.5 0 52.9 43.1 96 96 96s96-43.1 96-96c0-41.7-26.8-77.3-64-90.5v-101.5h224v101.5c-37.2 13.2-64 48.8-64 90.5 0 52.9 43.1 96 96 96s96-43.1 96-96c0-41.7-26.8-77.3-64-90.5v-101.5h224v101.5c-37.2 13.2-64 48.8-64 90.5 0 52.9 43.1 96 96 96s96-43.1 96-96c0-41.7-26.8-77.3-64-90.5zM320 160c0-35.3 28.7-64 64-64s64 28.7 64 64-28.7 64-64 64c-35.3 0-64-28.7-64-64zM160 608c0 35.3-28.7 64-64 64s-64-28.7-64-64 28.7-64 64-64 64 28.7 64 64zM448 608c0 35.3-28.7 64-64 64s-64-28.7-64-64 28.7-64 64-64c35.3 0 64 28.7 64 64zM672 672c-35.3 0-64-28.7-64-64s28.7-64 64-64 64 28.7 64 64-28.7 64-64 64z"></path>
</svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title>bricks</title>
<path d="M704 0h-640c-35.3 0-64 28.7-64 64v640c0 35.3 28.7 64 64 64h640c35.3 0 64-28.7 64-64v-640c0-35.3-28.7-64-64-64zM704 128h-128v-64h128v64zM320 128v-64h224v64h-224zM448 160v128h-256v-128h256zM320 448v-128h224v128h-224zM160 288h-96v-128h96v128zM288 320v128h-224v-128h224zM160 480v128h-96v-128h96zM192 480h256v128h-256v-128zM544 640v64h-224v-64h224zM480 608v-128h224v128h-224zM576 448v-128h128v128h-128zM480 288v-128h224v128h-224zM288 64v64h-224v-64h224zM64 640h224v64h-224v-64zM704 704h-128v-64h128.1l-0.1 64c0.1 0 0 0 0 0z"></path>
</svg>

After

Width:  |  Height:  |  Size: 701 B

View File

@@ -0,0 +1,14 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title></title>
<g id="icomoon-ignore">
</g>
<path d="M0 32h160v64h-160v-64z"></path>
<path d="M288 32h192v64h-192v-64z"></path>
<path d="M608 32h160v64h-160v-64z"></path>
<path d="M192 0h64v224h-64v-224z"></path>
<path d="M512 0h64v224h-64v-224z"></path>
<path d="M288 128h192v64h-192v-64z"></path>
<path d="M704 128h-96v64h96v512h-640v-512h96v-64h-96c-35.3 0-64 28.7-64 64v512c0 35.3 28.7 64 64 64h640c35.3 0 64-28.7 64-64v-512c0-35.3-28.7-64-64-64z"></path>
<path d="M128 304v320c0 8.8 7.2 16 16 16h480c8.8 0 16-7.2 16-16v-320c0-8.8-7.2-16-16-16h-480c-8.8 0-16 7.2-16 16zM160 480h128v128h-128v-128zM448 480v128h-128v-128h128zM320 448v-128h128v128h-128zM480 608v-128h128v128h-128zM608 448h-128v-128h128v128zM288 320v128h-128v-128h128z"></path>
</svg>

After

Width:  |  Height:  |  Size: 888 B

View File

@@ -0,0 +1,39 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title></title>
<g id="icomoon-ignore">
</g>
<path d="M0 32h160v64h-160v-64z"></path>
<path d="M288 32h192v64h-192v-64z"></path>
<path d="M608 32h160v64h-160v-64z"></path>
<path d="M192 0h64v224h-64v-224z"></path>
<path d="M512 0h64v224h-64v-224z"></path>
<path d="M288 128h192v64h-192v-64z"></path>
<path d="M704 128h-96v64h96v512h-480v-144c0-8.8-7.2-16-16-16h-144v-352h96v-64h-96c-35.3 0-64 28.7-64 64v352c0 123.5 100.5 224 224 224h480c35.3 0 64-28.7 64-64v-512c0-35.3-28.7-64-64-64zM67.2 576h124.8v124.8c-62.6-12.8-112-62.2-124.8-124.8z"></path>
<path d="M352 288h64v32h-64v-32z"></path>
<path d="M448 288h64v32h-64v-32z"></path>
<path d="M544 288h64v32h-64v-32z"></path>
<path d="M256 352h64v32h-64v-32z"></path>
<path d="M352 352h64v32h-64v-32z"></path>
<path d="M448 352h64v32h-64v-32z"></path>
<path d="M544 352h64v32h-64v-32z"></path>
<path d="M160 352h64v32h-64v-32z"></path>
<path d="M256 416h64v32h-64v-32z"></path>
<path d="M352 416h64v32h-64v-32z"></path>
<path d="M448 416h64v32h-64v-32z"></path>
<path d="M544 416h64v32h-64v-32z"></path>
<path d="M160 416h64v32h-64v-32z"></path>
<path d="M256 480h64v32h-64v-32z"></path>
<path d="M352 480h64v32h-64v-32z"></path>
<path d="M448 480h64v32h-64v-32z"></path>
<path d="M544 480h64v32h-64v-32z"></path>
<path d="M160 480h64v32h-64v-32z"></path>
<path d="M256 544h64v32h-64v-32z"></path>
<path d="M352 544h64v32h-64v-32z"></path>
<path d="M448 544h64v32h-64v-32z"></path>
<path d="M544 544h64v32h-64v-32z"></path>
<path d="M256 608h64v32h-64v-32z"></path>
<path d="M352 608h64v32h-64v-32z"></path>
<path d="M448 608h64v32h-64v-32z"></path>
<path d="M544 608h64v32h-64v-32z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,26 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title></title>
<g id="icomoon-ignore">
</g>
<path d="M0 32h160v64h-160v-64z"></path>
<path d="M288 32h192v64h-192v-64z"></path>
<path d="M608 32h160v64h-160v-64z"></path>
<path d="M192 0h64v224h-64v-224z"></path>
<path d="M512 0h64v224h-64v-224z"></path>
<path d="M288 128h192v64h-192v-64z"></path>
<path d="M352 288h64v32h-64v-32z"></path>
<path d="M448 288h64v32h-64v-32z"></path>
<path d="M544 288h64v32h-64v-32z"></path>
<path d="M256 352h64v32h-64v-32z"></path>
<path d="M352 352h64v32h-64v-32z"></path>
<path d="M160 352h64v32h-64v-32z"></path>
<path d="M256 416h64v32h-64v-32z"></path>
<path d="M160 416h64v32h-64v-32z"></path>
<path d="M256 480h64v32h-64v-32z"></path>
<path d="M160 480h64v32h-64v-32z"></path>
<path d="M256 544h64v32h-64v-32z"></path>
<path d="M256 608h64v32h-64v-32z"></path>
<path d="M704 128h-96v64h96v218c-37.4-35.9-88.2-58-144-58-114.7 0-208 93.3-208 208 0 55.8 22.1 106.6 58 144h-186v-144c0-8.8-7.2-16-16-16h-144v-352h96v-64h-96c-35.3 0-64 28.7-64 64v352c0 123.5 100.5 224 224 224h336c114.7 0 208-93.3 208-208v-368c0-35.3-28.7-64-64-64zM67.2 576h124.8v124.8c-62.6-12.8-112-62.2-124.8-124.8zM576 735.3v-63.3h-32v63.3c-84.3-7.6-151.6-75-159.3-159.3h63.3v-32h-63.3c7.6-84.3 75-151.6 159.3-159.3v63.3h32v-63.3c84.3 7.6 151.6 75 159.3 159.3h-63.3v32h63.3c-7.7 84.3-75 151.6-159.3 159.3z"></path>
<path d="M557.5 566.9l-61.5-61.5-22.6 22.6 75.3 75.3c3 3 7.1 4.7 11.3 4.7 0.5 0 1.1 0 1.6-0.1 4.8-0.5 9.1-3.1 11.7-7l72.9-109.3-26.6-17.8-62.1 93.1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,7 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title></title>
<g id="icomoon-ignore">
</g>
<path d="M768 256c0-88.2-71.8-160-160-160-25.2 0-50.4 6-72.7 17.4-6.5 3.3-12.8 7.1-18.8 11.3-8-13.1-17.5-25.4-28.7-36.5-36.3-36.2-84.5-56.2-135.8-56.2s-99.5 20-135.8 56.2-56.2 84.5-56.2 135.8c0 30.3 7.2 60.4 20.9 87.2 10.7 21 25.5 40.1 43.1 55.9v32.4l-179.4-76.9c-9.9-4.2-21.2-3.2-30.2 2.7s-14.4 15.9-14.4 26.7v352c0 11.4 6.1 22 15.9 27.7 5 2.9 10.5 4.3 16.1 4.3 5.5 0 11-1.4 15.9-4.2l176.1-100.7v40.9c0 35.3 28.7 64 64 64h416c35.3 0 64-28.7 64-64v-320c0-13.6-4.2-26.1-11.5-36.5 7.6-18.8 11.5-39 11.5-59.5zM704 608h-416v-256h416v256zM608 160c52.9 0 96 43.1 96 96 0 11-1.9 21.8-5.5 32h-165.4c7.2-20.3 10.9-41.9 10.9-64 0-12.4-1.2-24.5-3.4-36.4 17.9-17.7 41.8-27.6 67.4-27.6zM352 96c70.6 0 128 57.4 128 128 0 22.8-5.9 44.7-17.1 64h-174.9c-14.4 0-27.7 4.8-38.4 12.9-16.5-21.9-25.6-48.6-25.6-76.9 0-70.6 57.4-128 128-128zM64 648.9v-248.4l160 68.6v88.3l-160 91.5zM288 672.1c0 0 0-0.1 0 0v-32.1h416v32l-416 0.1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,11 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title></title>
<g id="icomoon-ignore">
</g>
<path d="M512 480h-64c-17.7 0-32 14.3-32 32v240c0 8.8 7.2 16 16 16h96c8.8 0 16-7.2 16-16v-240c0-17.7-14.3-32-32-32zM448 736v-192h32v192h-32z"></path>
<path d="M320 352h-64c-17.7 0-32 14.3-32 32v368c0 8.8 7.2 16 16 16h96c8.8 0 16-7.2 16-16v-368c0-17.7-14.3-32-32-32zM256 736v-320h32v320h-32z"></path>
<path d="M128 480h-64c-17.7 0-32 14.3-32 32v240c0 8.8 7.2 16 16 16h96c8.8 0 16-7.2 16-16v-240c0-17.7-14.3-32-32-32zM64 736v-192h32v192h-32z"></path>
<path d="M704 224h-64c-17.7 0-32 14.3-32 32v496c0 8.8 7.2 16 16 16h96c8.8 0 16-7.2 16-16v-496c0-17.7-14.3-32-32-32zM640 736v-448h32v448h-32z"></path>
<path d="M752 0h-80v32h38.2l-281.7 222.4-216.1-61.7c-5-1.4-10.3-0.3-14.4 2.9l-172.5 138 20 25 166-132.8 216.1 61.7c4.9 1.4 10.3 0.4 14.3-2.8l294.1-232.3v43.6h32v-80c0-8.8-7.2-16-16-16z"></path>
</svg>

After

Width:  |  Height:  |  Size: 980 B

View File

@@ -0,0 +1,6 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title>chart</title>
<path d="M288 544c35.3 0 64-28.7 64-64 0-11.7-3.2-22.8-8.7-32.2l73-127.8c3.2 0 6.3-0.3 9.4-0.7l58.7 73.4c-2.8 7.2-4.4 15.1-4.4 23.3 0 35.3 28.7 64 64 64s64-28.7 64-64c0-15.6-5.6-30-15-41.1l90.1-247.9c30-5.3 52.9-31.5 52.9-63 0-35.3-28.7-64-64-64s-64 28.7-64 64c0 15.6 5.6 30 15 41.1l-89.6 246.4-57.8-72.2c2.8-7.2 4.4-15.1 4.4-23.3 0-35.3-28.7-64-64-64s-64 28.7-64 64c0 11.7 3.2 22.8 8.7 32.2l-73 127.8c-35.2 0.2-63.7 28.8-63.7 64 0 35.3 28.7 64 64 64zM544 448c-17.6 0-32-14.4-32-32s14.4-32 32-32 32 14.4 32 32-14.4 32-32 32zM672 32c17.6 0 32 14.4 32 32s-14.4 32-32 32-32-14.4-32-32 14.4-32 32-32zM416 224c17.6 0 32 14.4 32 32s-14.4 32-32 32-32-14.4-32-32 14.4-32 32-32zM288 448c17.6 0 32 14.4 32 32s-14.4 32-32 32-32-14.4-32-32 14.4-32 32-32z"></path>
<path d="M758.6 649.4l-72-72-45.3 45.3 17.4 17.4h-530.7v-530.8l17.4 17.4 45.3-45.3-72-72c-12.5-12.5-32.8-12.5-45.3 0l-72 72 45.3 45.3 17.3-17.4v562.7c0 17.7 14.3 32 32 32h562.7l-17.4 17.4 45.3 45.3 72-72c12.5-12.6 12.5-32.8 0-45.3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,9 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title></title>
<g id="icomoon-ignore">
</g>
<path d="M752 256h-402l374-96.5c8.5-2.2 13.7-10.8 11.5-19.4l-32-128c-2.1-8.6-10.8-13.8-19.4-11.6l-560.9 140.2c-12.5-8-27.3-12.7-43.2-12.7-44.1 0-80 35.9-80 80v496c0 35.3 28.7 64 64 64h640c35.3 0 64-28.7 64-64v-432c0-8.8-7.2-16-16-16v0zM272 384c10.1-13.4 16-30 16-48s-6-34.6-16-48h121.4l-96 96h-25.4zM438.6 288h114.7l-96 96h-114.7l96-96zM598.6 288h114.7l-96 96h-114.7l96-96zM736 310.6v73.4h-73.4l73.4-73.4zM208 256c-26.5 0-48-21.5-48-48 0-14.9-4.1-28.8-11.2-40.7l88.6-22.2 124.4 74.8-139.8 36.1h-14zM488.7 187.2l-83.3 21.5-124-74.5 84.1-21 123.2 74zM409.3 102.2l84.1-21 122 73.3-83.3 21.5-122.8-73.8zM700.6 132.5l-41.7 10.8-121.6-73.1 139-34.8 24.3 97.1zM32 208c0-26.5 21.5-48 48-48s48 21.5 48 48c0 44.1 35.9 80 80 80 26.5 0 48 21.5 48 48s-21.5 48-48 48h-128c-26.5 0-48-21.5-48-48v-128zM64 704v-289.6c5.2 1.1 10.5 1.6 16 1.6h624v288h-640z"></path>
<path d="M96 352c17.6 0 32-14.4 32-32s-14.4-32-32-32-32 14.4-32 32 14.4 32 32 32z"></path>
<path d="M295.6 669.6c2.6 1.6 5.5 2.4 8.4 2.4 2.4 0 4.9-0.6 7.2-1.7l192-96c5.4-2.7 8.8-8.3 8.8-14.3s-3.4-11.6-8.8-14.3l-192-96c-5-2.5-10.8-2.2-15.6 0.7-4.7 2.9-7.6 8.1-7.6 13.6v192c0 5.5 2.9 10.7 7.6 13.6zM320 489.9l140.2 70.1-140.2 70.1v-140.2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,6 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title>cloud-check</title>
<path d="M765.7 386.1c1.5-11.3 2.3-22.7 2.3-34.1 0-76.9-30-149.3-84.3-203.7-54.4-54.3-126.8-84.3-203.7-84.3-60 0-117.6 18.3-166.4 52.9-40 28.3-71.6 65.7-92.7 109.3-9.5-1.4-19.1-2.2-28.9-2.2-51.3 0-99.5 20-135.8 56.2s-56.2 84.5-56.2 135.8 20 99.5 56.2 135.8c36.3 36.2 84.5 56.2 135.8 56.2h192.8c37 57.7 101.7 96 175.2 96 114.7 0 208-93.3 208-208 0-23.8-4-46.7-11.4-68 4.2-13.6 7.3-27.7 9.1-41.9zM192 544c-70.6 0-128-57.4-128-128s57.4-128 128-128c43.2 0 83.1 21.5 106.9 57.6l53.4-35.2c-18-27.3-42.1-49-70-63.9 38.6-71.9 114.6-118.5 197.7-118.5 121.5 0 220.7 97.2 223.9 218-37.4-35.9-88.1-58-143.9-58-114.7 0-208 93.3-208 208 0 16.5 1.9 32.6 5.6 48h-165.6zM560 672c-97 0-176-79-176-176s79-176 176-176 176 79 176 176-79 176-176 176z"></path>
<path d="M528 537.4l-64-64-22.6 22.6 75.3 75.3c3.1 3.1 7.2 4.7 11.3 4.7s8.2-1.6 11.3-4.7l139.3-139.3-22.6-22.6-128 128z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,6 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title>cloud-cross</title>
<path d="M765.7 386.1c1.5-11.3 2.3-22.7 2.3-34.1 0-76.9-30-149.3-84.3-203.7-54.4-54.3-126.8-84.3-203.7-84.3-60 0-117.6 18.3-166.4 52.9-40 28.3-71.6 65.7-92.7 109.3-9.5-1.4-19.1-2.2-28.9-2.2-51.3 0-99.5 20-135.8 56.2s-56.2 84.5-56.2 135.8 20 99.5 56.2 135.8c36.3 36.2 84.5 56.2 135.8 56.2h192.8c37 57.7 101.7 96 175.2 96 114.7 0 208-93.3 208-208 0-23.8-4-46.7-11.4-68 4.2-13.6 7.3-27.7 9.1-41.9zM192 544c-70.6 0-128-57.4-128-128s57.4-128 128-128c43.2 0 83.1 21.5 106.9 57.6l53.4-35.2c-18-27.3-42.1-49-70-63.9 38.6-71.9 114.6-118.5 197.7-118.5 121.5 0 220.7 97.2 223.9 218-37.4-35.9-88.1-58-143.9-58-114.7 0-208 93.3-208 208 0 16.5 1.9 32.6 5.6 48h-165.6zM560 672c-97 0-176-79-176-176s79-176 176-176 176 79 176 176-79 176-176 176z"></path>
<path d="M624 409.4l-64 64-64-64-22.6 22.6 64 64-64 64 22.6 22.6 64-64 64 64 22.6-22.6-64-64 64-64z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1016 B

View File

@@ -0,0 +1,6 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="768" height="768" viewBox="0 0 768 768">
<title>cloud-lock</title>
<path d="M765.7 386.1c1.5-11.3 2.3-22.7 2.3-34.1 0-76.9-30-149.3-84.3-203.7-54.4-54.3-126.8-84.3-203.7-84.3-60 0-117.6 18.3-166.4 52.9-40 28.3-71.6 65.7-92.7 109.3-9.5-1.4-19.1-2.2-28.9-2.2-51.3 0-99.5 20-135.8 56.2s-56.2 84.5-56.2 135.8 20 99.5 56.2 135.8c36.3 36.2 84.5 56.2 135.8 56.2h192.8c37 57.7 101.7 96 175.2 96 114.7 0 208-93.3 208-208 0-23.8-4-46.7-11.4-68 4.2-13.6 7.3-27.7 9.1-41.9zM192 544c-70.6 0-128-57.4-128-128s57.4-128 128-128c43.2 0 83.1 21.5 106.9 57.6l53.4-35.2c-18-27.3-42.1-49-70-63.9 38.6-71.9 114.6-118.5 197.7-118.5 121.5 0 220.7 97.2 223.9 218-37.4-35.9-88.1-58-143.9-58-114.7 0-208 93.3-208 208 0 16.5 1.9 32.6 5.6 48h-165.6zM560 672c-97 0-176-79-176-176s79-176 176-176 176 79 176 176-79 176-176 176z"></path>
<path d="M656 480h-16v-16c0-44.1-35.9-80-80-80s-80 35.9-80 80v16h-16c-8.8 0-16 7.2-16 16v96c0 8.8 7.2 16 16 16h192c8.8 0 16-7.2 16-16v-96c0-8.8-7.2-16-16-16zM512 464c0-26.5 21.5-48 48-48s48 21.5 48 48v16h-96v-16zM640 576h-160v-64h160v64z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Some files were not shown because too many files have changed in this diff Show More