Merge pull request #72 from KevinMidboe/feat/refactor

Feat Refactor
This commit is contained in:
2022-08-08 14:11:44 +02:00
committed by GitHub
145 changed files with 10001 additions and 8494 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,45 +1,45 @@
{
"name": "seasoned-request",
"description": "seasoned request app",
"version": "1.0.0",
"version": "1.22.17",
"author": "Kevin Midboe",
"private": true,
"scripts": {
"dev": "cross-env NODE_ENV=development webpack-dev-server --hot",
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules",
"dev": "cross-env NODE_ENV=development webpack server",
"build": "cross-env 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"
},
"dependencies": {
"axios": "^0.18.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"chart.js": "^2.9.2",
"connect-history-api-fallback": "^1.3.0",
"express": "^4.16.1",
"vue": "^2.5.2",
"vue-axios": "^1.2.2",
"vue-data-tablee": "^0.12.1",
"vue-js-modal": "^1.3.16",
"vue-router": "^3.0.1",
"vuex": "^3.1.0"
"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"
},
"devDependencies": {
"@babel/core": "^7.4.5",
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.4.5",
"@babel/runtime": "^7.4.5",
"babel-loader": "^8.0.6",
"cross-env": "^3.0.0",
"css-loader": "^3.4.2",
"@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",
"file-loader": "^0.9.0",
"node-sass": "^4.5.0",
"sass-loader": "^5.0.1",
"schema-utils": "^2.4.1",
"vue-loader": "^10.0.0",
"vue-svg-inline-loader": "^1.3.1",
"vue-template-compiler": "2.6.10",
"webpack": "^2.2.0",
"webpack-dev-server": "^2.2.0"
"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",
"vue-loader": "17.0.0",
"webpack": "5.70.0",
"webpack-cli": "4.9.2",
"webpack-dev-server": "4.7.4"
}
}

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

BIN
public/assets/dune.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 275 KiB

View File

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 423 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 889 B

After

Width:  |  Height:  |  Size: 889 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,23 +1,18 @@
var express = require('express');
var path = require('path');
const compression = require('compression')
var history = require('connect-history-api-fallback');
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.use(compression())
app.use('/dist', express.static(path.join(__dirname + "/dist")));
app.use('/dist', express.static(path.join(__dirname + "/dist/")));
app.use('/favicons', express.static(path.join(__dirname + "/favicons")));
app.use(history({
index: '/'
}));
var port = process.env.PORT || 5000;
app.get('/', function(req, res) {
res.sendFile(path.join(__dirname + '/index.html'));
app.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,159 +1,77 @@
<template>
<div id="app">
<!-- Header and hamburger navigation -->
<navigation></navigation>
<NavigationHeader class="header"></NavigationHeader>
<!-- Header with search field -->
<!-- TODO move this to the navigation component -->
<header class="header">
<search-input v-model="query"></search-input>
</header>
<!-- Movie popup that will show above existing rendered content -->
<movie-popup v-if="moviePopupIsVisible" :id="popupID" :type="popupType"></movie-popup>
<darkmode-toggle />
<div class="navigation-icons-gutter desktop-only">
<NavigationIcons />
</div>
<!-- Display the component assigned to the given route (default: home) -->
<router-view class="content" :key="$route.fullPath"></router-view>
<!-- Popup that will show above existing rendered content -->
<popup />
<darkmode-toggle />
</div>
</template>
<script>
import Vue from 'vue'
import Navigation from '@/components/Navigation'
import MoviePopup from '@/components/MoviePopup'
import SearchInput from '@/components/SearchInput'
import DarkmodeToggle from '@/components/ui/darkmodeToggle'
import NavigationHeader from "@/components/header/NavigationHeader";
import NavigationIcons from "@/components/header/NavigationIcons";
import Popup from "@/components/Popup";
import DarkmodeToggle from "@/components/ui/DarkmodeToggle";
export default {
name: 'app',
name: "app",
components: {
Navigation,
MoviePopup,
SearchInput,
NavigationHeader,
NavigationIcons,
Popup,
DarkmodeToggle
},
data() {
return {
query: '',
moviePopupIsVisible: false,
popupID: 0,
popupType: 'movie'
}
},
created(){
let that = this
Vue.prototype.$popup = {
get isOpen() {
return that.moviePopupIsVisible
},
open: (id, type) => {
this.popupID = id || this.popupID
this.popupType = type || this.popupType
this.moviePopupIsVisible = true
console.log('opened')
},
close: () => {
this.moviePopupIsVisible = false
console.log('closed')
}
}
console.log('MoviePopup registered at this.$popup and has state: ', this.$popup.isOpen)
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/media-queries";
@import "./src/scss/variables";
.content {
@include tablet-min{
width: calc(100% - 95px);
margin-top: $header-size;
margin-left: 95px;
position: relative;
}
}
</style>
<style lang="scss">
// @import "./src/scss/main";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "src/scss/main";
@import "src/scss/media-queries";
*{
box-sizing: border-box;
}
html {
height: 100%;
}
body{
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
line-height: 1.6;
background: $background-color;
color: $text-color;
transition: background-color .5s ease, color .5s ease;
&.hidden{
overflow: hidden;
#app {
display: grid;
grid-template-rows: var(--header-size);
grid-template-columns: var(--header-size) 1fr;
@include mobile {
grid-template-columns: 1fr;
}
}
h1,h2,h3 {
transition: color .5s ease;
}
a:any-link {
color: inherit;
}
input, textarea, button{
font-family: 'Roboto', sans-serif;
}
figure{
padding: 0;
margin: 0;
}
img{
display: block;
// max-width: 100%;
height: auto;
}
.no-scroll {
overflow: hidden;
}
.wrapper{
position: relative;
}
.header{
position: fixed;
z-index: 15;
display: flex;
flex-direction: column;
@include tablet-min{
width: calc(100% - 170px);
margin-left: 95px;
border-top: 0;
border-bottom: 0;
.header {
position: fixed;
top: 0;
width: 100%;
z-index: 15;
}
}
// router view transition
.fade-enter-active, .fade-leave-active {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter, .fade-leave-active {
opacity: 0
.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,490 +0,0 @@
import axios from 'axios'
import storage from '@/storage'
import config from '@/config.json'
import path from 'path'
import store from '@/store'
const SEASONED_URL = config.SEASONED_URL
const ELASTIC_URL = config.ELASTIC_URL
const ELASTIC_INDEX = config.ELASTIC_INDEX
// TODO
// - Move autorization token and errors here?
const checkStatusAndReturnJson = (response) => {
if (!response.ok) {
throw resp
}
return response.json()
}
// - - - TMDB - - -
/**
* Fetches tmdb movie by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getMovie = (id, checkExistance=false, credits=false, release_dates=false) => {
const url = new URL('v2/movie', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
if (checkExistance) {
url.searchParams.append('check_existance', true)
}
if (credits) {
url.searchParams.append('credits', true)
}
if(release_dates) {
url.searchParams.append('release_dates', true)
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.error(`api error getting movie: ${id}`); throw error })
}
/**
* Fetches tmdb show by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getShow = (id, checkExistance=false, credits=false) => {
const url = new URL('v2/show', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
if (checkExistance) {
url.searchParams.append('check_existance', true)
}
if (credits) {
url.searchParams.append('credits', true)
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.error(`api error getting show: ${id}`); throw error })
}
/**
* Fetches tmdb person by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getPerson = (id, credits=false) => {
const url = new URL('v2/person', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
if (credits) {
url.searchParams.append('credits', true)
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.error(`api error getting person: ${id}`); throw error })
}
/**
* Fetches tmdb list by name.
* @param {string} name List the fetch
* @param {number} [page=1]
* @returns {object} Tmdb list response
*/
const getTmdbMovieListByName = (name, page=1) => {
const url = new URL('v2/movie/' + name, SEASONED_URL)
url.searchParams.append('page', page)
const headers = { authorization: storage.token }
return fetch(url.href, { headers: headers })
.then(resp => resp.json())
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error })
}
/**
* Fetches requested items.
* @param {number} [page=1]
* @returns {object} Request response
*/
const getRequests = (page=1) => {
const url = new URL('v2/request', SEASONED_URL)
url.searchParams.append('page', page)
const headers = { authorization: storage.token }
return fetch(url.href, { headers: headers })
.then(resp => resp.json())
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error })
}
const getUserRequests = (page=1) => {
const url = new URL('v1/user/requests', SEASONED_URL)
url.searchParams.append('page', page)
const headers = { authorization: localStorage.getItem('token') }
return fetch(url.href, { headers })
.then(resp => resp.json())
}
/**
* Fetches tmdb movies and shows by query.
* @param {string} query
* @param {number} [page=1]
* @returns {object} Tmdb response
*/
const searchTmdb = (query, page=1, adult=false, mediaType=null) => {
const url = new URL('v2/search', SEASONED_URL)
if (mediaType != null && ['movie', 'show', 'person'].includes(mediaType)) {
url.pathname += `/${mediaType}`
}
url.searchParams.append('query', query)
url.searchParams.append('page', page)
url.searchParams.append('adult', adult)
const headers = { authorization: localStorage.getItem('token') }
return fetch(url.href, { headers })
.then(resp => resp.json())
.catch(error => { console.error(`api error searching: ${query}, page: ${page}`); throw error })
}
// - - - Torrents - - -
/**
* Search for torrents by query
* @param {string} query
* @param {boolean} credits Include credits
* @returns {object} Torrent response
*/
const searchTorrents = (query, authorization_token) => {
const url = new URL('/api/v1/pirate/search', SEASONED_URL)
url.searchParams.append('query', query)
const headers = { authorization: storage.token }
return fetch(url.href, { headers: headers })
.then(resp => resp.json())
.catch(error => { console.error(`api error searching torrents: ${query}`); throw error })
}
/**
* Add magnet to download queue.
* @param {string} magnet Magnet link
* @param {boolean} name Name of torrent
* @param {boolean} tmdb_id
* @returns {object} Success/Failure response
*/
const addMagnet = (magnet, name, tmdb_id) => {
const url = new URL('v1/pirate/add', SEASONED_URL)
const body = JSON.stringify({
magnet: magnet,
name: name,
tmdb_id: tmdb_id
})
const headers = {
'Content-Type': 'application/json',
authorization: storage.token
}
return fetch(url.href, {
method: 'POST',
headers,
body
})
.then(resp => resp.json())
.catch(error => { console.error(`api error adding magnet: ${name} ${error}`); throw error })
}
// - - - Plex/Request - - -
/**
* Request a movie or show from id. If authorization token is included the user will be linked
* to the requested item.
* @param {number} id Movie or show id
* @param {string} type Movie or show type
* @param {string} [authorization_token] To identify the requesting user
* @returns {object} Success/Failure response
*/
const request = (id, type, authorization_token=undefined) => {
const url = new URL('v2/request', SEASONED_URL)
// url.pathname = path.join(url.pathname, id.toString())
// url.searchParams.append('type', type)
const headers = {
'Authorization': authorization_token,
'Content-Type': 'application/json'
}
const body = {
id: id,
type: type
}
return fetch(url.href, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
})
.then(resp => resp.json())
.catch(error => { console.error(`api error requesting: ${id}, type: ${type}`); throw error })
}
/**
* Check request status by tmdb id and type
* @param {number} tmdb id
* @param {string} type
* @returns {object} Success/Failure response
*/
const getRequestStatus = (id, type, authorization_token=undefined) => {
const url = new URL('v2/request', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
url.searchParams.append('type', type)
return fetch(url.href)
.then(resp => {
const status = resp.status;
if (status === 200) { return true }
else if (status === 404) { return false }
else {
console.error(`api error getting request status for id ${id} and type ${type}`)
}
})
.catch(err => Promise.reject(err))
}
const watchLink = (title, year, authorization_token=undefined) => {
const url = new URL('v1/plex/watch-link', SEASONED_URL)
url.searchParams.append('title', title)
url.searchParams.append('year', year)
const headers = {
'Authorization': authorization_token,
'Content-Type': 'application/json'
}
return fetch(url.href, { headers })
.then(resp => resp.json())
.then(response => response.link)
}
// - - - Seasoned user endpoints - - -
const register = (username, password) => {
const url = new URL('v1/user', SEASONED_URL)
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
}
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error('Unexpected error occured before receiving response. Error:', error)
// TODO log to sentry the issue here
throw error
})
}
const login = (username, password, throwError=false) => {
const url = new URL('v1/user/login', SEASONED_URL)
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
}
return fetch(url.href, options)
.then(resp => {
if (resp.status == 200)
return resp.json();
if (throwError)
throw resp;
else
console.error("Error occured when trying to sign in.\nError:", resp);
})
}
const getSettings = () => {
const settingsExists = (value) => {
if (value instanceof Object && value.hasOwnProperty('settings'))
return value;
throw "Settings does not exist in response object.";
}
const commitSettingsToStore = (response) => {
store.dispatch('userModule/setSettings', response.settings)
return response
}
const url = new URL('v1/user/settings', SEASONED_URL)
const authorization_token = localStorage.getItem('token')
const headers = authorization_token ? {
'Authorization': authorization_token,
'Content-Type': 'application/json'
} : {}
return fetch(url.href, { headers })
.then(resp => resp.json())
.then(settingsExists)
.then(commitSettingsToStore)
.then(response => response.settings)
.catch(error => { console.log('api error getting user settings'); throw error })
}
const updateSettings = (settings) => {
const url = new URL('v1/user/settings', SEASONED_URL)
const authorization_token = localStorage.getItem('token')
const headers = authorization_token ? {
'Authorization': authorization_token,
'Content-Type': 'application/json'
} : {}
return fetch(url.href, {
method: 'PUT',
headers,
body: JSON.stringify(settings)
})
.then(resp => resp.json())
.catch(error => { console.log('api error updating user settings'); throw error })
}
// - - - Authenticate with plex - - -
const linkPlexAccount = (username, password) => {
const url = new URL('v1/user/link_plex', SEASONED_URL)
const body = { username, password }
const headers = {
'Content-Type': 'application/json',
authorization: storage.token
}
return fetch(url.href, {
method: 'POST',
headers,
body: JSON.stringify(body)
})
.then(resp => resp.json())
.catch(error => { console.error(`api error linking plex account: ${username}`); throw error })
}
const unlinkPlexAccount = (username, password) => {
const url = new URL('v1/user/unlink_plex', SEASONED_URL)
const headers = {
'Content-Type': 'application/json',
authorization: storage.token
}
return fetch(url.href, {
method: 'POST',
headers
})
.then(resp => resp.json())
.catch(error => { console.error(`api error unlinking plex account: ${username}`); throw error })
}
// - - - User graphs - - -
const fetchChart = (urlPath, days, chartType) => {
const url = new URL('v1/user' + urlPath, SEASONED_URL)
url.searchParams.append('days', days)
url.searchParams.append('y_axis', chartType)
const authorization_token = localStorage.getItem('token')
const headers = authorization_token ? {
'Authorization': authorization_token,
'Content-Type': 'application/json'
} : {}
return fetch(url.href, { headers })
.then(resp => resp.json())
.catch(error => { console.log('api error fetching chart'); throw error })
}
// - - - Random emoji - - -
const getEmoji = () => {
const url = new URL('v1/emoji', SEASONED_URL)
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.log('api error getting emoji'); throw error })
}
// - - - ELASTIC SEARCH - - -
// This elastic index contains titles mapped to ids. Lightning search
// used for autocomplete
/**
* Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and
* Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports
* @param {string} query
* @returns {object} List of movies and shows matching query
*/
const elasticSearchMoviesAndShows = (query) => {
const url = new URL(path.join(ELASTIC_INDEX, '/_search'), ELASTIC_URL)
const headers = {
'Content-Type': 'application/json'
}
const body = {
"sort" : [
{ "popularity" : {"order" : "desc"}},
"_score"
],
"query": {
"bool": {
"should": [{
"match_phrase_prefix": {
"original_name": query
}
},
{
"match_phrase_prefix": {
"original_title": query
}
}]
}
},
"size": 6
}
return fetch(url.href, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
})
.then(resp => resp.json())
.catch(error => { console.log(`api error searching elasticsearch: ${query}`); throw error })
}
export {
getMovie,
getShow,
getPerson,
getTmdbMovieListByName,
searchTmdb,
getUserRequests,
getRequests,
searchTorrents,
addMagnet,
request,
watchLink,
getRequestStatus,
linkPlexAccount,
unlinkPlexAccount,
register,
login,
getSettings,
updateSettings,
fetchChart,
getEmoji,
elasticSearchMoviesAndShows
}

563
src/api.ts Normal file
View File

@@ -0,0 +1,563 @@
import config from "./config";
import { IList } 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();
};
// - - - TMDB - - -
/**
* Fetches tmdb movie by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getMovie = (
id,
checkExistance = false,
credits = false,
release_dates = false
) => {
const url = new URL("/api/v2/movie", SEASONED_URL);
url.pathname = `${url.pathname}/${id.toString()}`;
if (checkExistance) {
url.searchParams.append("check_existance", "true");
}
if (credits) {
url.searchParams.append("credits", "true");
}
if (release_dates) {
url.searchParams.append("release_dates", "true");
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting movie: ${id}`);
throw error;
});
};
/**
* Fetches tmdb show by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getShow = (id, checkExistance = false, credits = false) => {
const url = new URL("/api/v2/show", SEASONED_URL);
url.pathname = `${url.pathname}/${id.toString()}`;
if (checkExistance) {
url.searchParams.append("check_existance", "true");
}
if (credits) {
url.searchParams.append("credits", "true");
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting show: ${id}`);
throw error;
});
};
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
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getPerson = (id, credits = false) => {
const url = new URL("/api/v2/person", SEASONED_URL);
url.pathname = `${url.pathname}/${id.toString()}`;
if (credits) {
url.searchParams.append("credits", "true");
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting person: ${id}`);
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);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting movie: ${id}`);
throw error;
});
};
/**
* Fetches tmdb show credits by id.
* @param {number} id
* @returns {object} Tmdb response
*/
const getShowCredits = id => {
const url = new URL("/api/v2/show", SEASONED_URL);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting show: ${id}`);
throw error;
});
};
/**
* Fetches tmdb person credits by id.
* @param {number} id
* @returns {object} Tmdb response
*/
const getPersonCredits = id => {
const url = new URL("/api/v2/person", SEASONED_URL);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting person: ${id}`);
throw error;
});
};
/**
* Fetches tmdb list by name.
* @param {string} name List the fetch
* @param {number} [page=1]
* @returns {object} Tmdb list response
*/
const getTmdbMovieListByName = (
name: string,
page: number = 1
): Promise<IList> => {
const url = new URL("/api/v2/movie/" + name, SEASONED_URL);
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 })
};
/**
* Fetches requested items.
* @param {number} [page=1]
* @returns {object} Request response
*/
const getRequests = (page: number = 1) => {
const url = new URL("/api/v2/request", SEASONED_URL);
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 })
};
const getUserRequests = (page = 1) => {
const url = new URL("/api/v1/user/requests", SEASONED_URL);
url.searchParams.append("page", page.toString());
return fetch(url.href).then(resp => resp.json());
};
/**
* Fetches tmdb movies and shows by query.
* @param {string} query
* @param {number} [page=1]
* @returns {object} Tmdb response
*/
const searchTmdb = (query, page = 1, adult = false, mediaType = null) => {
const url = new URL("/api/v2/search", SEASONED_URL);
if (mediaType != null && ["movie", "show", "person"].includes(mediaType)) {
url.pathname += `/${mediaType}`;
}
url.searchParams.append("query", query);
url.searchParams.append("page", page.toString());
url.searchParams.append("adult", adult.toString());
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error searching: ${query}, page: ${page}`);
throw error;
});
};
// - - - Torrents - - -
/**
* Search for torrents by query
* @param {string} query
* @param {boolean} credits Include credits
* @returns {object} Torrent response
*/
const searchTorrents = query => {
const url = new URL("/api/v1/pirate/search", SEASONED_URL);
url.searchParams.append("query", query);
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error searching torrents: ${query}`);
throw error;
});
};
/**
* Add magnet to download queue.
* @param {string} magnet Magnet link
* @param {boolean} name Name of torrent
* @param {boolean} tmdb_id
* @returns {object} Success/Failure response
*/
const addMagnet = (magnet, name, tmdb_id) => {
const url = new URL("/api/v1/pirate/add", SEASONED_URL);
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
magnet: magnet,
name: name,
tmdb_id: tmdb_id
})
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error adding magnet: ${name} ${error}`);
throw error;
});
};
// - - - Plex/Request - - -
/**
* Request a movie or show from id. If authorization token is included the user will be linked
* to the requested item.
* @param {number} id Movie or show id
* @param {string} type Movie or show type
* @returns {object} Success/Failure response
*/
const request = (id, type) => {
const url = new URL("/api/v2/request", SEASONED_URL);
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, type })
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error requesting: ${id}, type: ${type}`);
throw error;
});
};
/**
* Check request status by tmdb id and type
* @param {number} tmdb id
* @param {string} type
* @returns {object} Success/Failure response
*/
const getRequestStatus = (id, type = undefined) => {
const url = new URL("/api/v2/request", SEASONED_URL);
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}`
);
}
})
.catch(err => Promise.reject(err));
};
const watchLink = (title, year) => {
const url = new URL("/api/v1/plex/watch-link", SEASONED_URL);
url.searchParams.append("title", title);
url.searchParams.append("year", year);
return fetch(url.href)
.then(resp => resp.json())
.then(response => response.link);
};
const movieImages = id => {
const url = new URL(`v2/movie/${id}/images`, SEASONED_URL);
return fetch(url.href).then(resp => resp.json());
};
// - - - Seasoned user endpoints - - -
const register = (username, password) => {
const url = new URL("/api/v1/user", SEASONED_URL);
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(
"Unexpected error occured before receiving response. Error:",
error
);
// TODO log to sentry the issue here
throw error;
});
};
const login = (username, password, throwError = false) => {
const url = new URL("/api/v1/user/login", SEASONED_URL);
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
};
return fetch(url.href, options).then(resp => {
if (resp.status == 200) return resp.json();
if (throwError) throw resp;
else console.error("Error occured when trying to sign in.\nError:", resp);
});
};
const logout = (throwError = false) => {
const url = new URL("/api/v1/user/logout", SEASONED_URL);
const options = { method: "POST" };
return fetch(url.href, options).then(resp => {
if (resp.status == 200) return resp.json();
if (throwError) throw resp;
else console.error("Error occured when trying to log out.\nError:", resp);
});
};
const getSettings = () => {
const url = new URL("/api/v1/user/settings", SEASONED_URL);
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.log("api error getting user settings");
throw error;
});
};
const updateSettings = settings => {
const url = new URL("/api/v1/user/settings", SEASONED_URL);
const options = {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings)
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log("api error updating user settings");
throw error;
});
};
// - - - Authenticate with plex - - -
const linkPlexAccount = (username, password) => {
const url = new URL("/api/v1/user/link_plex", SEASONED_URL);
const body = { username, password };
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error linking plex account: ${username}`);
throw error;
});
};
const unlinkPlexAccount = (username, password) => {
const url = new URL("/api/v1/user/unlink_plex", SEASONED_URL);
const options = {
method: "POST",
headers: { "Content-Type": "application/json" }
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error unlinking plex account: ${username}`);
throw error;
});
};
// - - - User graphs - - -
const fetchChart = (urlPath, days, chartType) => {
const url = new URL("/api/v1/user" + urlPath, SEASONED_URL);
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);
throw Error(resp.statusText);
}
return resp.json();
});
};
// - - - Random emoji - - -
const getEmoji = () => {
const url = new URL("/api/v1/emoji", SEASONED_URL);
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.log("api error getting emoji");
throw error;
});
};
// - - - ELASTIC SEARCH - - -
// This elastic index contains titles mapped to ids. Lightning search
// used for autocomplete
/**
* Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and
* Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports
* @param {string} query
* @returns {object} List of movies and shows matching query
*/
const elasticSearchMoviesAndShows = (query, count = 22) => {
const url = new URL(`${ELASTIC_INDEX}/_search`, ELASTIC_URL);
const body = {
sort: [{ popularity: { order: "desc" } }, "_score"],
query: {
bool: {
should: [
{
match_phrase_prefix: {
original_name: query
}
},
{
match_phrase_prefix: {
original_title: query
}
}
]
}
},
size: count
};
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log(`api error searching elasticsearch: ${query}`);
throw error;
});
};
export {
getMovie,
getShow,
getPerson,
getMovieCredits,
getShowCredits,
getPersonCredits,
getCredits,
getTmdbMovieListByName,
searchTmdb,
getUserRequests,
getRequests,
searchTorrents,
addMagnet,
request,
watchLink,
movieImages,
getRequestStatus,
linkPlexAccount,
unlinkPlexAccount,
register,
login,
logout,
getSettings,
updateSettings,
fetchChart,
getEmoji,
elasticSearchMoviesAndShows
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,75 +0,0 @@
<template>
<div>
<section class="not-found">
<h1 class="not-found__title">Page Not Found</h1>
</section>
<seasoned-button class="button" @click="goBack">go back to previous page</seasoned-button>
</div>
</template>
<script>
import store from '@/store'
import SeasonedButton from '@/components/ui/SeasonedButton'
export default {
components: { SeasonedButton },
methods: {
goBack() {
this.$router.go(-1)
}
},
created() {
if (this.$popup.isOpen == true)
this.$popup.close()
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.button {
font-size: 1.2rem;
position: fixed;
top: 50%;
left: calc(50% + 46px);
transform: translate(-50%, -50%);
@include mobile {
top: 60%;
left: 50%;
font-size: 1rem;
width: content;
}
}
.not-found {
display: flex;
height: calc(100vh - var(--header-size));
background: url('~assets/pulp-fiction.jpg') no-repeat 50% 50%;
background-size: cover;
align-items: center;
flex-direction: column;
&::before {
content: "";
position: absolute;
height: calc(100vh - var(--header-size));
width: 100%;
pointer-events: none;
background: $background-40;
}
&__title {
margin-top: 30vh;
font-size: 2.5rem;
font-weight: 500;
color: $text-color;
position: relative;
@include tablet-min {
font-size: 3.5rem;
}
}
}
</style>

View File

@@ -1,316 +0,0 @@
<template>
<div class="wrapper" v-if="hasPlexUser">
<h1>Your watch activity</h1>
<div class="filter">
<h2>Filter</h2>
<div class="filter-item">
<label class="desktop-only">Days:</label>
<input class="dayinput"
v-model="days"
placeholder="number of days"
type="number"
pattern="[0-9]*"
:style="{maxWidth: `${3 + (0.5 * days.length)}rem`}"/>
<!-- <datalist id="days">
<option v-for="index in 1500" :value="index" :key="index"></option>
</datalist> -->
</div>
<toggle-button class="filter-item" :options="chartTypes" :selected.sync="selectedChartDataType" />
</div>
<div class="chart-section">
<h3 class="chart-header">Activity per day:</h3>
<div class="chart">
<canvas ref="activityCanvas"></canvas>
</div>
<h3 class="chart-header">Activity per day of week:</h3>
<div class="chart">
<canvas ref="playsByDayOfWeekCanvas"></canvas>
</div>
</div>
</div>
<div v-else>
<h1>Must be authenticated</h1>
</div>
</template>
<script>
import store from '@/store'
import ToggleButton from '@/components/ui/ToggleButton';
import { fetchChart } from '@/api'
var Chart = require('chart.js');
Chart.defaults.global.elements.point.radius = 0
Chart.defaults.global.elements.point.hitRadius = 10
Chart.defaults.global.elements.point.pointHoverRadius = 10
Chart.defaults.global.elements.point.hoverBorderWidth = 4
export default {
components: { ToggleButton },
data() {
return {
days: 30,
selectedChartDataType: 'plays',
charts: [{
name: 'Watch activity',
ref: 'activityCanvas',
data: null,
urlPath: '/plays_by_day',
graphType: 'line'
}, {
name: 'Plays by day of week',
ref: 'playsByDayOfWeekCanvas',
data: null,
urlPath: '/plays_by_dayofweek',
graphType: 'bar'
}],
chartData: [{
type: 'plays',
tooltipLabel: 'Play count',
},{
type: 'duration',
tooltipLabel: 'Watched duration',
valueConvertFunction: this.convertSecondsToHumanReadable
}],
gridColor: getComputedStyle(document.documentElement).getPropertyValue('--text-color-5')
}
},
computed: {
hasPlexUser() {
return store.getters['userModule/plex_userid'] != null ? true : false
},
chartTypes() {
return this.chartData.map(chart => chart.type)
},
selectedChartType() {
return this.chartData.filter(data => data.type == this.selectedChartDataType)[0]
}
},
watch: {
hasPlexUser(newValue, oldValue) {
if (newValue != oldValue && newValue == true) {
this.fetchChartData(this.charts)
}
},
days(newValue) {
if (newValue !== '') {
this.fetchChartData(this.charts)
}
},
selectedChartDataType(selectedChartDataType) {
this.fetchChartData(this.charts)
}
},
beforeMount() {
if (typeof(this.days) == 'number') {
this.days = this.days.toString()
}
},
methods: {
fetchChartData(charts) {
if (this.hasPlexUser == false) {
return
}
for (let chart of charts) {
fetchChart(chart.urlPath, this.days, this.selectedChartType.type)
.then(data => {
this.series = data.data.series.filter(group => group.name === 'TV')[0].data; // plays pr date in groups (movie/tv/music)
this.categories = data.data.categories; // dates
const x_labels = data.data.categories.map(date => {
if (date.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)) {
const [year, month, day] = date.split('-')
return `${day}.${month}`
}
return date
})
let y_activityMovies = data.data.series.filter(group => group.name === 'Movies')[0].data
let y_activityTV = data.data.series.filter(group => group.name === 'TV')[0].data
const datasets = [{
label: `Movies watch last ${ this.days } days`,
data: y_activityMovies,
backgroundColor: 'rgba(54, 162, 235, 0.2)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1
},
{
label: `Shows watch last ${ this.days } days`,
data: y_activityTV,
backgroundColor: 'rgba(255, 159, 64, 0.2)',
borderColor: 'rgba(255, 159, 64, 1)',
borderWidth: 1
}
]
if (chart.data == null) {
this.generateChart(chart, x_labels, datasets)
} else {
chart.data.clear();
chart.data.data.labels = x_labels;
chart.data.data.datasets = datasets;
chart.data.update();
}
})
}
},
generateChart(chart, labels, datasets) {
const chartInstance = new Chart(this.$refs[chart.ref], {
type: chart.graphType,
data: {
labels: labels,
datasets: datasets
},
options: {
// hitRadius: 8,
maintainAspectRatio: false,
tooltips: {
callbacks: {
title: (tooltipItem, data) => `Watch date: ${tooltipItem[0].label}`,
label: (tooltipItem, data) => {
let label = data.datasets[tooltipItem.datasetIndex].label
let value = tooltipItem.value;
let text = 'Duration watched'
const context = label.split(' ')[0]
if (context) {
text = `${context} ${this.selectedChartType.tooltipLabel.toLowerCase()}`
}
if (this.selectedChartType.valueConvertFunction) {
value = this.selectedChartType.valueConvertFunction(tooltipItem.value)
}
return ` ${text}: ${value}`
}
}
},
scales: {
yAxes: [{
gridLines: {
color: this.gridColor
},
stacked: chart.graphType === 'bar',
ticks: {
// suggestedMax: 10000,
callback: (value, index, values) => {
if (this.selectedChartType.valueConvertFunction) {
return this.selectedChartType.valueConvertFunction(value, values)
}
return value
},
beginAtZero: true
}
}],
xAxes: [{
stacked: chart.graphType === 'bar',
gridLines: {
display: false,
}
}]
}
}
});
chart.data = chartInstance;
},
convertSecondsToHumanReadable(value, values=null) {
const highestValue = values ? values[0] : value;
// minutes
if (highestValue < 3600) {
const minutes = Math.floor(value / 60);
value = `${minutes} m`
}
// hours and minutes
else if (highestValue > 3600 && highestValue < 86400) {
const hours = Math.floor(value / 3600);
const minutes = Math.floor(value % 3600 / 60);
value = hours != 0 ? `${hours} h ${minutes} m` : `${minutes} m`
}
// days and hours
else if (highestValue > 86400 && highestValue < 31557600) {
const days = Math.floor(value / 86400);
const hours = Math.floor(value % 86400 / 3600);
value = days != 0 ? `${days} d ${hours} h` : `${hours} h`
}
// years and days
else if (highestValue > 31557600) {
const years = Math.floor(value / 31557600);
const days = Math.floor(value % 31557600 / 86400);
value = years != 0 ? `${years} y ${days} d` : `${days} d`
}
return value
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
.wrapper {
padding: 2rem;
@include mobile-only {
padding: 0 0.8rem;
}
}
.filter {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
margin-bottom: 2rem;
h2 {
margin-bottom: 0.5rem;
width: 100%;
font-weight: 400;
}
&-item:not(:first-of-type) {
margin-left: 1rem;
}
.dayinput {
font-size: 1.2rem;
max-width: 3rem;
background-color: $background-ui;
color: $text-color;
}
}
.chart-section {
display: flex;
flex-wrap: wrap;
.chart {
position: relative;
height: 35vh;
width: 90vw;
margin-bottom: 2rem;
}
.chart-header {
font-weight: 300;
}
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="cast">
<ol class="persons">
<CastListItem v-for="person in cast" :person="person" :key="person.id" />
</ol>
</div>
</template>
<script>
import CastListItem from "src/components/CastListItem";
export default {
name: "CastList",
components: { CastListItem },
props: {
cast: {
type: Array,
required: true
}
}
};
</script>
<style lang="scss">
.cast {
position: relative;
top: 0;
left: 0;
ol {
overflow-x: scroll;
padding: 0;
list-style-type: none;
margin: 0;
display: flex;
scrollbar-width: none; /* for Firefox */
&::-webkit-scrollbar {
display: none; /* for Chrome, Safari, and Opera */
}
}
}
</style>

View File

@@ -0,0 +1,121 @@
<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>
</li>
</template>
<script>
import { mapActions } from "vuex";
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";
}
}
};
</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;
}
&: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>

View File

@@ -1,87 +0,0 @@
<template>
<section>
<LandingBanner />
<div v-for="list in lists">
<list-header :title="list.title" :link="'/list/' + list.route" />
<results-list :results="list.data" :shortList="true" />
<loader v-if="!list.data.length" />
</div>
</section>
</template>
<script>
import LandingBanner from "@/components/LandingBanner";
import ListHeader from "@/components/ListHeader";
import ResultsList from "@/components/ResultsList";
import Loader from "@/components/ui/Loader";
import { getTmdbMovieListByName, getRequests } from "@/api";
export default {
name: "home",
components: { LandingBanner, ResultsList, ListHeader, Loader },
data() {
return {
imageFile: "/pulp-fiction.jpg",
requests: [],
nowplaying: [],
upcoming: [],
popular: []
};
},
computed: {
lists() {
return [
{
title: "Requests",
route: "request",
data: this.requests
},
{
title: "Now playing",
route: "now_playing",
data: this.nowplaying
},
{
title: "Upcoming",
route: "upcoming",
data: this.upcoming
},
{
title: "Popular",
route: "popular",
data: this.popular
}
];
}
},
methods: {
fetchRequests() {
getRequests().then(results => (this.requests = results.results));
},
fetchNowPlaying() {
getTmdbMovieListByName("now_playing").then(
results => (this.nowplaying = results.results)
);
},
fetchUpcoming() {
getTmdbMovieListByName("upcoming").then(
results => (this.upcoming = results.results)
);
},
fetchPopular() {
getTmdbMovieListByName("popular").then(
results => (this.popular = results.results)
);
}
},
created() {
this.fetchRequests();
this.fetchNowPlaying();
this.fetchUpcoming();
this.fetchPopular();
}
};
</script>

View File

@@ -1,14 +1,28 @@
<template>
<header v-bind:style="{ 'background-image': 'url(' + imageFile + ')' }">
<header
:class="{ expanded, noselect: true }"
v-bind:style="{ 'background-image': 'url(' + imageFile + ')' }"
>
<div class="container">
<h1 class="title">Request new movies or tv shows for plex</h1>
<strong class="subtitle">Made with Vue.js</strong>
<h1 class="title">Request movies or tv shows</h1>
<strong class="subtitle"
>Create a profile to track and view requests</strong
>
</div>
<div class="expand-icon" @click="expanded = !expanded">
<IconExpand v-if="!expanded" />
<IconShrink v-else />
</div>
</header>
</template>
<script>
import IconExpand from "../icons/IconExpand.vue";
import IconShrink from "../icons/IconShrink.vue";
export default {
components: { IconExpand, IconShrink },
props: {
image: {
type: String,
@@ -17,24 +31,35 @@ export default {
},
data() {
return {
imageFile: "/pulp-fiction.jpg"
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)]
}`;
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "src/scss/variables";
@import "src/scss/media-queries";
header {
width: 100%;
height: 200px;
height: 25vh;
display: flex;
align-items: center;
justify-content: center;
@@ -43,8 +68,48 @@ header {
background-position: 50% 50%;
position: relative;
@include tablet-min {
height: 284px;
&.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 {
@@ -54,8 +119,8 @@ header {
left: 0;
width: 100%;
height: 100%;
background-color: $background-70;
transition: background-color 0.5s ease;
background-color: var(--background-70);
transition: inherit;
}
.container {
@@ -71,9 +136,10 @@ header {
letter-spacing: 0.5px;
color: $text-color;
margin: 0;
opacity: 1;
@include tablet-min {
font-size: 28px;
font-size: 2.5rem;
}
}
@@ -83,9 +149,10 @@ header {
font-weight: 300;
color: $text-color-70;
margin: 5px 0;
opacity: 1;
@include tablet-min {
font-size: 16px;
font-size: 1.3rem;
}
}
}

View File

@@ -1,15 +1,25 @@
<template>
<header :class="{ 'sticky': sticky }">
<h2>{{ title }}</h2>
<header>
<h2>{{ prettify }}</h2>
<h3>{{ subtitle }}</h3>
<div v-if="info instanceof Array" class="flex flex-direction-column">
<span v-for="item in info" class="info">{{ item }}</span>
</div>
<span v-else class="info">{{ info }}</span>
<router-link v-if="link" :to="link" class='view-more' :aria-label="`View all ${title}`">
<router-link
v-if="shortList"
:to="urlify"
class="view-more"
:aria-label="`View all ${title}`"
>
View All
</router-link>
</header>
<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>
@@ -19,10 +29,10 @@ export default {
type: String,
required: true
},
sticky: {
type: Boolean,
subtitle: {
type: String,
required: false,
default: true
default: null
},
info: {
type: [String, Array],
@@ -31,34 +41,43 @@ export default {
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';
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
header {
width: 100%;
min-height: 80px;
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 0.75rem;
padding-right: 0.75rem;
padding: 0.5rem 0.75rem;
background-color: $background-color;
&.sticky {
background-color: $background-color;
position: sticky;
position: -webkit-sticky;
top: $header-size;
z-index: 4;
}
position: sticky;
position: -webkit-sticky;
top: $header-size;
z-index: 1;
h2 {
font-size: 1.4rem;
@@ -72,16 +91,16 @@ header {
.view-more {
font-size: 0.9rem;
font-weight: 300;
letter-spacing: .5px;
letter-spacing: 0.5px;
color: $text-color-70;
text-decoration: none;
transition: color .5s ease;
transition: color 0.5s ease;
cursor: pointer;
&:after{
&:after {
content: " →";
}
&:hover{
&:hover {
color: $text-color;
}
}
@@ -89,18 +108,18 @@ header {
.info {
font-size: 13px;
font-weight: 300;
letter-spacing: .5px;
letter-spacing: 0.5px;
color: $text-color;
text-decoration: none;
text-align: right;
}
@include tablet-min {
padding-left: 1.25rem;;
padding-left: 1.25rem;
}
@include desktop-lg-min {
padding-left: 1.75rem;
}
}
</style>
</style>

View File

@@ -1,113 +0,0 @@
<template>
<div>
<list-header :title="listTitle" :info="info" :sticky="true" />
<results-list :results="results" v-if="results" />
<loader v-if="!results.length" />
<div v-if="page < totalPages" class="fullwidth-button">
<seasoned-button @click="loadMore">load more</seasoned-button>
</div>
</div>
</template>
<script>
import ListHeader from '@/components/ListHeader'
import ResultsList from '@/components/ResultsList'
import SeasonedButton from '@/components/ui/SeasonedButton'
import Loader from '@/components/ui/Loader'
import { getTmdbMovieListByName, getRequests } from '@/api'
import store from '@/store'
export default {
components: { ListHeader, ResultsList, SeasonedButton, Loader },
data() {
return {
legalTmdbLists: [ 'now_playing', 'upcoming', 'popular' ],
results: [],
page: 1,
totalPages: 0,
totalResults: 0,
loading: true
}
},
computed: {
listTitle() {
if (this.results.length === 0)
return ''
const routeListName = this.$route.params.name
console.log('routelistname', routeListName)
return routeListName.includes('_') ? routeListName.split('_').join(' ') : routeListName
},
info() {
if (this.results.length === 0)
return [null, null]
return [this.pageCount, this.resultCount]
},
resultCount() {
const loadedResults = this.results.length
const totalResults = this.totalResults < 10000 ? this.totalResults : '∞'
return `${loadedResults} of ${totalResults} results`
},
pageCount() {
return `Page ${this.page} of ${this.totalPages}`
}
},
methods: {
loadMore() {
console.log(this.$route)
this.loading = true;
this.page++
window.history.replaceState({}, 'search', `/#/${this.$route.fullPath}?page=${this.page}`)
this.init()
},
init() {
const routeListName = this.$route.params.name
if (routeListName === 'request') {
getRequests(this.page)
.then(results => {
this.results = this.results.concat(...results.results)
this.page = results.page
this.totalPages = results.total_pages
this.totalResults = results.total_results
})
} else if (this.legalTmdbLists.includes(routeListName)) {
getTmdbMovieListByName(routeListName, this.page)
.then(results => {
this.results = this.results.concat(...results.results)
this.page = results.page
this.totalPages = results.total_pages
this.totalResults = results.total_results
})
} else {
// TODO handle if list is not found
console.log('404 this is not a tmdb list')
}
this.loading = false
}
},
created() {
if (this.results.length === 0)
this.init()
store.dispatch('documentTitle/updateTitle', `${this.$router.history.current.name} ${this.$route.params.name}`)
}
}
</script>
<style lang="scss" scoped>
.fullwidth-button {
width: 100%;
margin: 1rem 0;
padding-bottom: 2rem;
display: flex;
justify-content: center;
}
</style>

View File

@@ -1,12 +0,0 @@
<template>
<div class="container info">
<movie :id="$route.params.id" :type="'page'"></movie>
</div>
</template>
<script>
import Movie from './Movie';
export default {
components: { Movie }
}
</script>

View File

@@ -1,104 +0,0 @@
<template>
<div class="movie-popup" @click="$popup.close()">
<div class="movie-popup__box" @click.stop>
<movie :id="id" :type="type"></movie>
<button class="movie-popup__close" @click="$popup.close()"></button>
</div>
<i class="loader"></i>
</div>
</template>
<script>
import Movie from './Movie';
export default {
props: {
id: {
type: Number,
required: true
},
type: {
type: String,
required: true
}
},
components: { Movie },
methods: {
checkEventForEscapeKey(event) {
if (event.keyCode == 27) {
this.$popup.close()
}
}
},
created(){
window.addEventListener('keyup', this.checkEventForEscapeKey)
document.getElementsByTagName("body")[0].classList += " no-scroll";
},
beforeDestroy() {
window.removeEventListener('keyup', this.checkEventForEscapeKey)
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
}
}
</script>
<style lang="scss">
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.movie-popup{
position: fixed;
top: 0;
left: 0;
z-index: 20;
width: 100%;
height: 100vh;
background: rgba($dark, 0.93);
-webkit-overflow-scrolling: touch;
overflow: auto;
&__box{
width: 100%;
max-width: 768px;
position: relative;
z-index: 5;
background: $background-color-secondary;
padding-bottom: 50px;
@include tablet-min{
padding-bottom: 0;
margin: 40px auto;
}
}
&__close{
display: block;
position: absolute;
top: 0;
right: 0;
border: 0;
background: transparent;
width: 40px;
height: 40px;
transition: background 0.5s ease;
cursor: pointer;
&:before,
&:after{
content: "";
display: block;
position: absolute;
top: 19px;
left: 10px;
width: 20px;
height: 2px;
background: $white;
}
&:before{
transform: rotate(45deg);
}
&:after{
transform: rotate(-45deg);
}
&:hover{
background: $green;
}
}
}
</style>

View File

@@ -1,249 +0,0 @@
<template>
<li class="movie-item" :class="{ shortList: shortList }">
<figure class="movie-item__poster">
<img
class="movie-item__img"
ref="poster-image"
@click="openMoviePopup(movie.id, movie.type)"
:alt="posterAltText"
:data-src="poster"
src="~assets/placeholder.png"
/>
<div v-if="movie.download" class="progress">
<progress :value="movie.download.progress" max="100"></progress>
<span>{{ movie.download.state }}: {{ movie.download.progress }}%</span>
</div>
</figure>
<div class="movie-item__info">
<p v-if="movie.title || movie.name">{{ movie.title || movie.name }}</p>
<p v-if="movie.year">{{ movie.year }}</p>
<p v-if="movie.type == 'person'">
Known for: {{ movie.known_for_department }}
</p>
</div>
</li>
</template>
<script>
import img from "../directives/v-image";
export default {
props: {
movie: {
type: Object,
required: true
},
shortList: {
type: Boolean,
required: false
}
},
directives: {
img: img
},
data() {
return {
poster: undefined,
observed: false,
posterSizes: [
{
id: "w500",
minWidth: 500
},
{
id: "w342",
minWidth: 342
},
{
id: "w185",
minWidth: 185
},
{
id: "w154",
minWidth: 0
}
]
};
},
computed: {
posterAltText: function () {
const type = this.movie.type || "";
const title = this.movie.title || this.movie.name;
return this.movie.poster
? `Poster for ${type} ${title}`
: `Missing image for ${type} ${title}`;
}
},
beforeMount() {
if (this.movie.poster != null) {
this.poster = "https://image.tmdb.org/t/p/w500" + this.movie.poster;
} else {
this.poster = "/no-image.png";
}
},
mounted() {
const poster = this.$refs["poster-image"];
if (poster == null) return;
const imageObserver = new IntersectionObserver((entries, imgObserver) => {
entries.forEach(entry => {
if (entry.isIntersecting && this.observed == false) {
const lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.className = lazyImage.className + " is-loaded";
this.observed = true;
}
});
});
imageObserver.observe(poster);
},
methods: {
openMoviePopup(id, type) {
this.$popup.open(id, type);
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/main";
.movie-item {
padding: 10px;
width: 50%;
background-color: $background-color;
transition: background-color 0.5s ease;
@include tablet-min {
padding: 15px;
width: 33%;
}
@include tablet-landscape-min {
padding: 15px;
width: 25%;
}
@include desktop-min {
padding: 15px;
width: 20%;
}
@include desktop-lg-min {
padding: 15px;
width: 12.5%;
}
&:hover &__info > p {
color: $text-color;
}
&__poster {
text-decoration: none;
color: $text-color-70;
font-weight: 300;
> img {
width: 100%;
opacity: 0;
transform: scale(0.97) translateZ(0);
transition: opacity 1s ease, transform 0.5s ease;
&.is-loaded {
opacity: 1;
transform: scale(1);
}
&:hover {
transform: scale(1.03);
box-shadow: 0 0 10px rgba($dark, 0.1);
}
}
}
&__info {
padding-top: 15px;
font-weight: 300;
> p {
color: $text-color-70;
margin: 0;
font-size: 11px;
letter-spacing: 0.5px;
transition: color 0.5s ease;
cursor: pointer;
@include mobile-ls-min {
font-size: 12px;
}
@include tablet-min {
font-size: 14px;
}
}
}
}
.no-image {
background-color: var(--text-color);
color: var(--background-color);
width: 100%;
height: 383px;
display: flex;
align-items: center;
justify-content: center;
span {
font-size: 1.5rem;
width: 70%;
text-align: center;
text-transform: uppercase;
}
&:hover {
transform: scale(1);
}
}
</style>
<style lang="scss" scoped>
@import "./src/scss/variables";
.progress {
position: absolute;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin-bottom: 0.8rem;
> progress {
width: 95%;
}
> span {
position: absolute;
font-size: 1rem;
line-height: 1.4rem;
color: $white;
}
progress {
border-radius: 4px;
height: 1.4rem;
}
progress::-webkit-progress-bar {
background-color: rgba($black, 0.55);
border-radius: 4px;
}
progress::-webkit-progress-value {
background-color: $green-70;
border-radius: 4px;
}
progress::-moz-progress-bar {
/* style rules */
background-color: green;
}
}
</style>

View File

@@ -1,314 +0,0 @@
<template>
<div>
<nav class="nav">
<router-link class="nav__logo" :to="{name: 'home'}" exact title="Vue.js — TMDb App">
<svg class="nav__logo-image">
<use xlink:href="#svgLogo"></use>
</svg>
</router-link>
<div class="nav__hamburger" @click="toggleNav">
<div v-for="_ in 3" class="bar"></div>
</div>
<ul class="nav__list">
<li class="nav__item" v-for="item in listTypes">
<router-link class="nav__link" :to="'/list/' + item.route">
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use :xlink:href="'#icon_' + item.route"></use>
</svg>
<span class="nav__link-title">{{ item.title }}</span>
</div>
</router-link>
</li>
<li class="nav__item nav__item--profile">
<router-link class="nav__link nav__link--profile" :to="{name: 'signin'}" v-if="!userLoggedIn">
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use xlink:href="#iconLogin"></use>
</svg>
<span class="nav__link-title">Sign in</span>
</div>
</router-link>
<router-link class="nav__link nav__link--profile" :to="{name: 'profile'}" v-if="userLoggedIn">
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use xlink:href="#iconLogin"></use>
</svg>
<span class="nav__link-title">Profile</span>
</div>
</router-link>
</li>
</ul>
</nav>
<div class="spacer"></div>
</div>
</template>
<script>
import storage from '@/storage'
export default {
data(){
return {
listTypes: storage.homepageLists,
userLoggedIn: localStorage.getItem('token') ? true : false
}
},
methods: {
setUserStatus(){
this.userLoggedIn = localStorage.getItem('token') ? true : false;
},
toggleNav(){
document.querySelector('.nav__hamburger').classList.toggle('nav__hamburger--active');
document.querySelector('.nav__list').classList.toggle('nav__list--active');
}
},
created(){
// TODO move this to state manager
eventHub.$on('setUserStatus', this.setUserStatus);
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.icon {
width: 30px;
}
.spacer {
@include mobile-only {
width: 100%;
height: $header-size;
}
}
.nav {
transition: background .5s ease;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 50px;
z-index: 10;
display: block;
color: $text-color;
background-color: $background-color-secondary;
@include tablet-min{
width: 95px;
height: 100vh;
}
&__logo {
width: 55px;
height: $header-size;
display: flex;
align-items: center;
justify-content: center;
background: $background-nav-logo;
@include tablet-min{
width: 95px;
}
&-image{
width: 35px;
height: 31px;
fill: $green;
transition: transform 0.5s ease;
@include tablet-min{
width: 45px;
height: 40px;
}
}
&:hover &-image {
transform: scale(1.04);
}
}
&__hamburger {
display: block;
position: fixed;
width: 55px;
height: 50px;
top: 0;
right: 0;
cursor: pointer;
z-index: 10;
border-left: 1px solid $background-color;
@include tablet-min{
display: none;
}
.bar {
position: absolute;
width: 23px;
height: 1px;
background-color: $text-color-70;
transition: all 300ms ease;
&:nth-child(1) {
left: 16px;
top: 17px;
}
&:nth-child(2) {
left: 16px;
top: 25px;
&:after {
content: "";
position: absolute;
left: 0px;
top: 0px;
width: 23px;
height: 1px;
transition: all 300ms ease;
}
}
&:nth-child(3) {
right: 15px;
top: 33px;
}
}
&--active {
.bar{
&:nth-child(1),
&:nth-child(3){
width: 0;
}
&:nth-child(2) {
transform: rotate(-45deg);
}
&:nth-child(2):after {
transform: rotate(-90deg);
// background: rgba($c-dark, 0.5);
background-color: $text-color-70;
}
}
}
}
&__list {
list-style: none;
padding: 0;
margin: 0;
text-align: center;
width: 100%;
position: fixed;
left: 0;
top: 50px;
border-top: 1px solid $background-color;
@include mobile-only {
display: flex;
flex-wrap: wrap;
font-size: 0;
opacity: 0;
visibility: hidden;
background-color: $background-95;
text-align: left;
&--active{
opacity: 1;
visibility: visible;
}
}
@include tablet-min {
display: flex;
position: relative;
display: block;
width: 100%;
border-top: 0;
top: 0;
}
}
&__item {
transition: background .5s ease, color .5s ease, border .5s ease;
background-color: $background-color-secondary;
color: $text-color-70;
@include mobile-only {
flex: 0 0 50%;
text-align: center;
border-bottom: 1px solid $background-color;
&:nth-child(odd){
border-right: 1px solid $background-color;
&:last-child {
// flex: 0 0 100%;
}
}
}
@include tablet-min {
width: 100%;
border-bottom: 1px solid $text-color-5;
&--profile {
position: fixed;
right: 0;
top: 0;
width: $header-size;
height: $header-size;
border-bottom: 0;
border-left: 1px solid $background-color;
}
}
&:hover, .is-active {
color: $text-color;
background-color: $background-color;
}
}
&__link {
background-color: inherit; // a elements have a transparent background
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
font-size: 7px;
font-weight: 300;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.5px;
position: relative;
cursor: pointer;
&-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
@include mobile-only {
font-size: 10px;
padding: 20px 0;
}
@include tablet-min {
width: 95px;
height: 95px;
font-size: 9px;
&--profile {
width: 75px;
height: 75px;
background-color: $background-color-secondary;
}
}
&-icon {
width: 20px;
height: 20px;
fill: $text-color-70;
@include tablet-min {
width: 20px;
height: 20px;
margin-bottom: 5px;
}
}
&-title {
margin-top: 5px;
display: block;
width: 100%;
}
&:hover &-icon, &.is-active &-icon {
fill: $text-color;
}
}
}
</style>

View File

@@ -1,63 +0,0 @@
<template>
<div class="persons">
<div class="persons--image" :style="{
'background-image': 'url(' + getPicture(info) + ')' }"></div>
<span>{{ info.name }}</span>
</div>
</template>
<script>
export default {
name: 'Person',
components: {
},
props: {
info: Object
},
data() {
return {
}
},
created() {},
beforeMount() {},
computed: {
},
methods: {
getPicture: (person) => {
if (person)
return 'https://image.tmdb.org/t/p/w185' + person.profile_path;
}
}
}
</script>
<style lang="scss">
.persons {
display: flex;
// border: 1px solid black;
flex-direction: column;
margin: 0 0.5rem;
span {
font-size: 0.6rem;
}
&--image {
border-radius: 50%;
height: 70px;
width: 70px;
// height: auto;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
}
&--name {
}
}
</style>

125
src/components/Popup.vue Normal file
View File

@@ -0,0 +1,125 @@
<template>
<div v-if="isOpen" class="movie-popup" @click="close">
<div class="movie-popup__box" @click.stop>
<person v-if="type === 'person'" :id="id" type="person" />
<movie v-else :id="id" :type="type"></movie>
<button class="movie-popup__close" @click="close"></button>
</div>
<i class="loader"></i>
</div>
</template>
<script>
import { mapActions, mapGetters } from "vuex";
import Movie from "@/components/popup/Movie";
import Person from "@/components/popup/Person";
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);
}
};
</script>
<style lang="scss">
@import "src/scss/variables";
@import "src/scss/media-queries";
.movie-popup {
position: fixed;
top: 0;
left: 0;
z-index: 20;
width: 100%;
height: 100%;
background: rgba($dark, 0.93);
-webkit-overflow-scrolling: touch;
overflow: auto;
&__box {
max-width: 768px;
position: relative;
z-index: 5;
margin: 8vh auto;
@include mobile {
margin: 0 0 50px 0;
}
}
&__close {
display: block;
position: absolute;
top: 0;
right: 0;
border: 0;
background: transparent;
width: 40px;
height: 40px;
transition: background 0.5s ease;
cursor: pointer;
z-index: 5;
&:before,
&:after {
content: "";
display: block;
position: absolute;
top: 19px;
left: 10px;
width: 20px;
height: 2px;
background: $white;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
&:hover {
background: $green;
}
}
}
</style>

View File

@@ -1,150 +0,0 @@
<template>
<section class="profile">
<div class="profile__content" v-if="userLoggedIn">
<header class="profile__header">
<h2 class="profile__title">{{ emoji }} Welcome {{ username }}</h2>
<div class="button--group">
<seasoned-button @click="toggleSettings">{{ showSettings ? 'hide settings' : 'show settings' }}</seasoned-button>
<seasoned-button @click="logOut">Log out</seasoned-button>
</div>
</header>
<settings v-if="showSettings"></settings>
<list-header title="User requests" :info="resultCount" />
<results-list v-if="results" :results="results" />
</div>
<section class="not-found" v-if="!userLoggedIn">
<div class="not-found__content">
<h2 class="not-found__title">Authentication Request Failed</h2>
<router-link :to="{name: 'signin'}" exact title="Sign in here">
<button class="not-found__button button">Sign In</button>
</router-link>
</div>
</section>
</section>
</template>
<script>
import storage from '@/storage'
import store from '@/store'
import ListHeader from '@/components/ListHeader'
import ResultsList from '@/components/ResultsList'
import Settings from '@/components/Settings'
import SeasonedButton from '@/components/ui/SeasonedButton'
import { getEmoji, getUserRequests } from '@/api'
export default {
components: { ListHeader, ResultsList, Settings, SeasonedButton },
data(){
return{
userLoggedIn: '',
emoji: '',
results: undefined,
totalResults: undefined,
showSettings: false
}
},
computed: {
resultCount() {
if (this.results === undefined)
return
const loadedResults = this.results.length
const totalResults = this.totalResults < 10000 ? this.totalResults : '∞'
return `${loadedResults} of ${totalResults} results`
},
username: () => store.getters['userModule/username']
},
methods: {
toggleSettings() {
this.showSettings = this.showSettings ? false : true;
if (this.showSettings) {
this.$router.replace({ query: { settings: true} })
} else {
this.$router.replace({ name: 'profile' })
}
},
logOut(){
this.$router.push('logout')
}
},
created(){
if(!localStorage.getItem('token')){
this.userLoggedIn = false;
} else {
this.userLoggedIn = true;
this.showSettings = window.location.toString().includes('settings=true')
getUserRequests()
.then(results => {
this.results = results.results
this.totalResults = results.total_results
})
getEmoji()
.then(resp => {
const { emoji } = resp
this.emoji = emoji
store.dispatch('documentTitle/updateEmoji', emoji)
})
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.button--group {
display: flex;
}
// DUPLICATE CODE
.profile{
&__header{
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid $text-color-5;
@include mobile-only {
flex-direction: column;
align-items: flex-start;
.button--group {
padding-top: 2rem;
}
}
@include tablet-min{
padding: 29px 30px;
}
@include tablet-landscape-min{
padding: 29px 50px;
}
@include desktop-min{
padding: 29px 60px;
}
}
&__title{
margin: 0;
font-size: 16px;
line-height: 16px;
color: $text-color;
font-weight: 300;
@include tablet-min{
font-size: 18px;
line-height: 18px;
}
}
}
</style>

View File

@@ -1,110 +0,0 @@
<template>
<section>
<h1>Register new user</h1>
<seasoned-input placeholder="username" icon="Email" type="username" :value.sync="username" @enter="submit"/>
<seasoned-input placeholder="password" icon="Keyhole" type="password" :value.sync="password" @enter="submit"/>
<seasoned-input placeholder="repeat password" icon="Keyhole" type="password" :value.sync="passwordRepeat" @enter="submit"/>
<seasoned-button @click="submit">Register</seasoned-button>
<router-link class="link" to="/signin">Have a user? Sign in here</router-link>
<seasoned-messages :messages.sync="messages"></seasoned-messages>
</section>
</template>
<script>
import { register } from '@/api'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedInput from '@/components/ui/SeasonedInput'
import SeasonedMessages from '@/components/ui/SeasonedMessages'
export default {
components: { SeasonedButton, SeasonedInput, SeasonedMessages },
data() {
return {
messages: [],
username: null,
password: null,
passwordRepeat: null
}
},
methods: {
submit() {
this.messages = [];
let { username, password, passwordRepeat } = this;
if (username == null || username.length == 0) {
this.messages.push({ type: 'error', title: 'Missing username' })
return
} else if (password == null || password.length == 0) {
this.messages.push({ type: 'error', title: 'Missing password' })
return
} else if (passwordRepeat == null || passwordRepeat.length == 0) {
this.messages.push({ type: 'error', title: 'Missing repeat password' })
return
} else if (passwordRepeat != password) {
this.messages.push({ type: 'error', title: 'Passwords do not match' })
return
}
this.registerUser(username, password)
},
registerUser(username, password) {
register(username, password, true)
.then(data => {
if (data.success){
localStorage.setItem('token', data.token);
const jwtData = parseJwt(data.token)
localStorage.setItem('username', jwtData['username']);
localStorage.setItem('admin', jwtData['admin'] || false);
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
})
.catch(error => {
if (error.status === 401) {
this.messages.push({ type: 'error', title: 'Access denied', message: 'Incorrect username or password' })
}
else {
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message })
}
});
},
logOut(){
localStorage.clear();
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'home' });
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
section {
padding: 1.3rem;
@include tablet-min {
padding: 4rem;
}
h1 {
margin: 0;
line-height: 16px;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
.link {
display: block;
width: max-content;
margin-top: 1rem;
}
}
</style>

View File

@@ -1,15 +1,26 @@
<template>
<ul class="results" :class="{'shortList': shortList}">
<movies-list-item v-for='movie in results' :movie="movie" />
</ul>
<template>
<div>
<ul
v-if="results && results.length"
class="results"
:class="{ shortList: shortList }"
>
<results-list-item
v-for="(movie, index) in results"
:key="`${movie.type}-${movie.id}-${index}`"
:movie="movie"
/>
</ul>
<span v-else-if="!loading" class="no-results">No results found</span>
</div>
</template>
<script>
import MoviesListItem from '@/components/MoviesListItem'
import ResultsListItem from "@/components/ResultsListItem";
export default {
components: { MoviesListItem },
components: { ResultsListItem },
props: {
results: {
type: Array,
@@ -19,50 +30,54 @@ export default {
type: Boolean,
required: false,
default: false
},
loading: {
type: Boolean,
required: false,
default: false
}
}
}
};
</script>
<style lang="scss">
@import "src/scss/media-queries";
@import "src/scss/main";
<style lang="scss" scoped>
@import './src/scss/media-queries';
.no-results {
width: 100%;
display: block;
text-align: center;
margin: 1.5rem;
font-size: 1.2rem;
}
.results {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
grid-auto-rows: auto;
margin: 0;
padding: 0;
list-style: none;
&.shortList > li {
display: none;
@include mobile {
grid-template-columns: repeat(2, 1fr);
}
&:nth-child(-n+4) {
display: block;
&.shortList {
overflow: auto;
grid-auto-flow: column;
max-width: 100vw;
@include noscrollbar;
> li {
min-width: 225px;
}
@include tablet-min {
max-width: calc(100vw - var(--header-size));
}
}
}
@include tablet-min {
.results.shortList > li:nth-child(-n+6) {
display: block;
}
}
@include tablet-landscape-min {
.results.shortList > li:nth-child(-n+8) {
display: block;
}
}
@include desktop-min {
.results.shortList > li:nth-child(-n+10) {
display: block;
}
}
@include desktop-lg-min {
.results.shortList > li:nth-child(-n+16) {
display: block;
}
}
</style>
</style>

View File

@@ -0,0 +1,180 @@
<template>
<li class="movie-item" ref="list-item">
<figure ref="poster" class="movie-item__poster" @click="openMoviePopup">
<img
class="movie-item__img"
:alt="posterAltText"
:data-src="poster"
src="/assets/placeholder.png"
/>
<div v-if="movie.download" class="progress">
<progress :value="movie.download.progress" max="100"></progress>
<span>{{ movie.download.state }}: {{ movie.download.progress }}%</span>
</div>
</figure>
<div class="movie-item__info">
<p v-if="movie.title || movie.name" class="movie-item__title">
{{ movie.title || movie.name }}
</p>
<p v-if="movie.year">{{ movie.year }}</p>
<p v-if="movie.type == 'person'">
Known for: {{ movie.known_for_department }}
</p>
</div>
</li>
</template>
<script>
import { mapActions } from "vuex";
import img from "../directives/v-image";
import { buildImageProxyUrl } from "../utils";
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;
}
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 imageObserver = new IntersectionObserver((entries, imgObserver) => {
entries.forEach(entry => {
if (entry.isIntersecting && this.observed == false) {
const lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
poster.className = poster.className + " is-loaded";
this.observed = true;
}
});
});
imageObserver.observe(this.image);
},
methods: {
...mapActions("popup", ["open"]),
openMoviePopup() {
this.open({
id: this.movie.id,
type: this.movie.type
});
}
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
.movie-item {
padding: 15px;
width: 100%;
background-color: var(--background-color);
&:hover &__info > p {
color: $text-color;
}
&__poster {
text-decoration: none;
color: $text-color-70;
font-weight: 300;
position: relative;
transform: scale(0.97) translateZ(0);
&::before {
content: "";
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
background-color: var(--background-color);
transition: 1s background-color ease;
}
&:hover {
transform: scale(1.03);
box-shadow: 0 0 10px rgba($dark, 0.1);
}
&.is-loaded::before {
background-color: transparent;
}
img {
width: 100%;
border-radius: 10px;
}
}
&__info {
padding-top: 10px;
font-weight: 300;
> p {
color: $text-color-70;
margin: 0;
font-size: 14px;
letter-spacing: 0.5px;
transition: color 0.5s ease;
cursor: pointer;
@include mobile-ls-min {
font-size: 12px;
}
@include tablet-min {
font-size: 14px;
}
}
}
&__title {
font-weight: 400;
}
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<div ref="resultSection" class="resultSection">
<list-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"
>load previous</seasoned-button
>
</div>
<results-list v-bind="{ results, shortList, loading }" />
<loader v-if="loading" />
<div ref="loadMoreButton" class="button-container">
<seasoned-button
class="load-button"
v-if="!loading && !shortList && page != totalPages && results.length"
@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";
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;
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
.resultSection {
background-color: var(--background-color);
}
.button-container {
display: flex;
justify-content: center;
display: flex;
width: 100%;
}
.load-button {
margin: 2rem 0;
@include mobile {
margin: 1rem 0;
}
&:last-of-type {
margin-bottom: 4rem;
@include mobile {
margin-bottom: 2rem;
}
}
}
</style>

View File

@@ -1,122 +0,0 @@
<template>
<div>
<list-header :title="title" :info="resultCount" :sticky="true" />
<results-list :results="results" />
<div v-if="page < totalPages" class="fullwidth-button">
<seasoned-button @click="loadMore">load more</seasoned-button>
</div>
<div class="notFound" v-if="results.length == 0 && loading == false">
<h1 class="notFound-title">No results for search: <b>{{ query }}</b></h1>
</div>
<loader v-if="loading" />
</div>
</template>
<style lang="scss" scoped>
.notFound {
display: flex;
justify-content: center;
align-items: center;
&-title {
font-weight: 400;
}
}
</style>
<script>
import { searchTmdb } from '@/api'
import ListHeader from '@/components/ListHeader'
import ResultsList from '@/components/ResultsList'
import SeasonedButton from '@/components/ui/SeasonedButton'
import Loader from '@/components/ui/Loader'
export default {
components: { ListHeader, ResultsList, SeasonedButton, Loader },
props: {
propQuery: {
type: String,
required: false
},
propPage: {
type: Number,
required: false
}
},
data() {
return {
loading: true,
query: String,
title: String,
page: Number,
adult: undefined,
mediaType: null,
totalPages: 0,
results: [],
totalResults: []
}
},
computed: {
resultCount() {
const loadedResults = this.results.length
const totalResults = this.totalResults < 10000 ? this.totalResults : '∞'
return `${loadedResults} of ${totalResults} results`
}
},
methods: {
search(query=this.query, page=this.page, adult=this.adult, mediaType=this.mediaType) {
searchTmdb(query, page, adult, mediaType)
.then(this.parseResponse)
},
parseResponse(data) {
if (this.results.length > 0) {
this.results.push(...data.results)
} else {
this.results = data.results
}
this.totalPages = data.total_pages
this.totalResults = data.total_results || data.results.length
this.loading = false
},
loadMore() {
this.page++
window.history.replaceState({}, 'search', `/#/search?query=${this.query}&page=${this.page}`)
this.search()
}
},
created() {
const { query, page, adult, media_type } = this.$route.query
if (!query) {
// abort
console.error('abort, no query')
}
this.query = decodeURIComponent(query)
this.page = page || 1
this.adult = adult || this.adult
this.mediaType = media_type || this.mediaType
this.title = `Search results: ${this.query}`
this.search()
}
}
</script>
<style lang="scss" scoped>
.fullwidth-button {
width: 100%;
margin: 1rem 0;
padding-bottom: 2rem;
display: flex;
justify-content: center;
}
</style>

View File

@@ -1,376 +0,0 @@
<template>
<div>
<div class="search">
<input
ref="input"
type="text"
placeholder="Search for movie or show"
aria-label="Search input for finding a movie or show"
autocorrect="off"
autocapitalize="off"
tabindex="1"
v-model="query"
@input="handleInput"
@click="focus = true"
@keydown.escape="handleEscape"
@keyup.enter="handleSubmit"
@keydown.up="navigateUp"
@keydown.down="navigateDown" />
<svg class="search-icon" fill="currentColor" @click="handleSubmit"><use xlink:href="#iconSearch"></use></svg>
</div>
<transition name="fade">
<div class="dropdown" v-if="!disabled && focus && query.length > 0">
<div class="filter">
<h2>Filter your search:</h2>
<div class="filter-items">
<toggle-button :options="searchTypes" :selected.sync="selectedSearchType" />
<label>Adult
<input type="checkbox" value="adult" v-model="adult">
</label>
</div>
</div>
<hr />
<div class="dropdown-results" v-if="elasticSearchResults.length">
<ul v-for="(item, index) in elasticSearchResults"
@click="openResult(item, index + 1)"
:class="{ active: index + 1 === selectedResult}">
{{ item.name }}
</ul>
</div>
<div v-else class="dropdown">
<div class="dropdown-results">
<h2 class="not-found">No results for query: <b>{{ query }}</b></h2>
</div>
</div>
<seasoned-button class="end-section" fullWidth="true"
@click="focus = false" :active="elasticSearchResults.length + 1 === selectedResult">
close
</seasoned-button>
</div>
</transition>
</div>
</template>
<script>
import SeasonedButton from '@/components/ui/SeasonedButton'
import ToggleButton from '@/components/ui/ToggleButton';
import { elasticSearchMoviesAndShows } from '@/api'
import config from '@/config.json'
export default {
name: 'SearchInput',
components: {
SeasonedButton,
ToggleButton
},
props: ['value'],
data() {
return {
adult: true,
searchTypes: ['all', 'movie', 'show', 'person'],
selectedSearchType: 'all',
query: this.value,
focus: false,
disabled: false,
scrollListener: undefined,
scrollDistance: 0,
elasticSearchResults: [],
selectedResult: 0,
}
},
watch: {
focus: function(val) {
if (val === true) {
window.addEventListener('scroll', this.disableFocus)
} else {
window.removeEventListener('scroll', this.disableFocus)
this.scrollDistance = 0
}
},
adult: function(value) {
this.handleInput()
}
},
beforeMount() {
const elasticUrl = config.ELASTIC_URL
if (elasticUrl === undefined || elasticUrl === false || elasticUrl === '') {
this.disabled = true
}
},
beforeDestroy() {
console.log('scroll eventlistener not removed, destroying!')
window.removeEventListener('scroll', this.disableFocus)
},
methods: {
navigateDown() {
this.focus = true
this.selectedResult++
},
navigateUp() {
this.focus = true
this.selectedResult--
const input = this.$refs.input;
const textLength = input.value.length
setTimeout(() => {
input.focus()
input.setSelectionRange(textLength, textLength + 1)
}, 1)
},
openResult(item, index) {
this.selectedResult = index;
this.$popup.open(item.id, item.type)
},
handleInput(e){
this.selectedResult = 0
this.$emit('input', this.query);
if (! this.focus) {
this.focus = true;
}
elasticSearchMoviesAndShows(this.query)
.then(resp => {
const data = resp.hits.hits
let results = data.map(item => {
const index = item._index.slice(0, -1)
if (index === 'movie' || item._source.original_title) {
return {
name: item._source.original_title,
id: item._source.id,
adult: item._source.adult,
type: 'movie'
}
} else if (index === 'show' || item._source.original_name) {
return {
name: item._source.original_name,
id: item._source.id,
adult: item._source.adult,
type: 'show'
}
}
})
results = this.removeDuplicates(results)
this.elasticSearchResults = results
})
},
removeDuplicates(searchResults) {
let filteredResults = []
searchResults.map(result => {
const numberOfDuplicates = filteredResults.filter(filterItem => filterItem.id == result.id)
if (numberOfDuplicates.length >= 1) {
return null
}
filteredResults.push(result)
})
if (this.adult == false) {
filteredResults = filteredResults.filter(result => result.adult == false)
}
return filteredResults
},
handleSubmit() {
let searchResults = this.elasticSearchResults
if (this.selectedResult > searchResults.length) {
this.focus = false
this.selectedResult = 0
} else if (this.selectedResult > 0) {
const resultItem = searchResults[this.selectedResult - 1]
this.$popup.open(resultItem.id, resultItem.type)
} else {
const encodedQuery = encodeURI(this.query.replace('/ /g, "+"'))
const media_type = this.selectedSearchType !== 'all' ? this.selectedSearchType : null
this.$router.push({ name: 'search', query: { query: encodedQuery, adult: this.adult, media_type }});
this.focus = false
this.selectedResult = 0
}
},
handleEscape() {
if (this.$popup.isOpen) {
console.log('THIS WAS FUCKOING OPEN!')
} else {
this.focus = false
}
},
disableFocus(_) {
this.focus = false
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import './src/scss/main';
.fade-enter-active {
transition: opacity .2s;
}
.fade-leave-active {
transition: opacity .2s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.filter {
// background-color: rgba(004, 122, 125, 0.2);
width: 100%;
display: flex;
flex-direction: column;
margin: 1rem 2rem;
h2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 400;
}
&-items {
display: flex;
flex-direction: row;
align-items: center;
> :not(:first-child) {
margin-left: 1rem;
}
}
}
hr {
display: block;
height: 1px;
border: 0;
border-bottom: 1px solid $text-color-50;
margin-top: 10px;
margin-bottom: 10px;
width: 90%;
}
.dropdown {
width: 100%;
position: relative;
display: flex;
flex-wrap: wrap;
z-index: 5;
min-height: $header-size;
right: 0px;
background-color: $background-color-secondary;
@include mobile-only {
position: fixed;
top: 50px;
padding-top: 20px;
width: calc(100%);
}
.not-found {
font-weight: 400;
}
&-results {
padding-left: 60px;
width: 100%;
@include mobile-only {
padding-left: 45px;
}
> ul {
font-size: 1.3rem;
padding: 0;
margin: 0.2rem 0;
width: calc(100% - 25px);
max-width: fit-content;
list-style: none;
color: rgba(0, 0, 0, 0.5);
text-transform: capitalize;
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
color: $text-color-50;
&.active, &:hover, &:active {
color: $text-color;
border-bottom: 2px solid $text-color;
}
}
}
}
.search {
height: $header-size;
display: flex;
position: fixed;
flex-wrap: wrap;
z-index: 5;
border: 0;
background-color: $background-color-secondary;
// TODO check if this is for mobile
width: calc(100% - 110px);
top: 0;
right: 55px;
@include tablet-min{
position: relative;
width: 100%;
right: 0px;
}
input {
display: block;
width: 100%;
padding: 13px 0 13px 45px;
outline: none;
margin: 0;
border: 0;
background-color: $background-color-secondary;
font-weight: 300;
font-size: 19px;
color: $text-color;
transition: background-color .5s ease, color .5s ease;
@include tablet-min {
padding: 13px 30px 13px 60px;
}
}
&-icon{
width: 20px;
height: 20px;
fill: $text-color-50;
transition: fill 0.5s ease;
pointer-events: none;
position: absolute;
left: 15px;
top: 15px;
@include tablet-min{
top: 27px;
left: 25px;
}
}
}
</style>

View File

@@ -1,198 +0,0 @@
<template>
<section class="profile">
<div class="profile__content" v-if="userLoggedIn">
<section class='settings'>
<h3 class='settings__header'>Plex account</h3>
<div v-if="!hasPlexUser">
<span class="settings__info">Sign in to your plex account to get information about recently added movies and to see your watch history</span>
<form class="form">
<seasoned-input placeholder="plex username" icon="Email" :value.sync="plexUsername"/>
<seasoned-input placeholder="plex password" icon="Keyhole" type="password"
:value.sync="plexPassword" @submit="authenticatePlex" />
<seasoned-button @click="authenticatePlex">link plex account</seasoned-button>
</form>
</div>
<div v-else>
<span class="settings__info">Awesome, your account is already authenticated with plex! Enjoy viewing your seasoned search history, plex watch history and real-time torrent download progress.</span>
<seasoned-button @click="unauthenticatePlex">un-link plex account</seasoned-button>
</div>
<seasoned-messages :messages.sync="messages" />
<hr class='setting__divider'>
<h3 class='settings__header'>Change password</h3>
<form class="form">
<seasoned-input placeholder="new password" icon="Keyhole" type="password"
:value.sync="newPassword" />
<seasoned-input placeholder="repeat new password" icon="Keyhole" type="password"
:value.sync="newPasswordRepeat" />
<seasoned-button @click="changePassword">change password</seasoned-button>
</form>
<hr class='setting__divider'>
</section>
</div>
<section class="not-found" v-else>
<div class="not-found__content">
<h2 class="not-found__title">Authentication Request Failed</h2>
<router-link :to="{name: 'signin'}" exact title="Sign in here">
<button class="not-found__button button">Sign In</button>
</router-link>
</div>
</section>
</section>
</template>
<script>
import store from '@/store'
import storage from '@/storage'
import SeasonedInput from '@/components/ui/SeasonedInput'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedMessages from '@/components/ui/SeasonedMessages'
import { getSettings, updateSettings, linkPlexAccount, unlinkPlexAccount } from '@/api'
export default {
components: { SeasonedInput, SeasonedButton, SeasonedMessages },
data(){
return{
userLoggedIn: '',
messages: [],
plexUsername: null,
plexPassword: null,
newPassword: null,
newPasswordRepeat: null,
emoji: null
}
},
computed: {
hasPlexUser: function() {
return this.settings && this.settings['plex_userid']
},
settings: {
get: () => {
return store.getters['userModule/settings']
},
set: function(newSettings) {
store.dispatch('userModule/setSettings', newSettings)
}
}
},
methods: {
setValue(l, t) {
this[l] = t
},
changePassword() {
return
},
async authenticatePlex() {
let username = this.plexUsername
let password = this.plexPassword
const response = await linkPlexAccount(username, password)
this.messages.push({
type: response.success ? 'success' : 'error',
title: response.success ? 'Authenticated with plex' : 'Something went wrong',
message: response.message
})
if (response.success)
getSettings().then(settings => this.settings = settings)
},
async unauthenticatePlex() {
const response = await unlinkPlexAccount()
this.messages.push({
type: response.success ? 'success' : 'error',
title: response.success ? 'Unlinked plex account ' : 'Something went wrong',
message: response.message
})
if (response.success)
getSettings().then(settings => this.settings = settings)
}
},
created(){
const token = localStorage.getItem('token') || false;
if (token){
this.userLoggedIn = true
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
a {
text-decoration: none;
}
// DUPLICATE CODE
.form {
> div:last-child {
margin-top: 1rem;
}
&__group{
justify-content: unset;
&__input-icon {
margin-top: 8px;
height: 22px;
width: 22px;
}
&-input {
padding: 10px 5px 10px 45px;
height: 40px;
font-size: 17px;
width: 75%;
@include desktop-min {
width: 400px;
}
}
}
}
.settings {
padding: 3rem;
@include mobile-only {
padding: 1rem;
}
&__header {
margin: 0;
line-height: 16px;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
&__info {
display: block;
margin-bottom: 25px;
}
hr {
display: block;
height: 1px;
border: 0;
border-bottom: 1px solid $text-color-50;
margin-top: 30px;
margin-bottom: 70px;
margin-left: 20px;
width: 96%;
text-align: left;
}
span {
font-weight: 200;
size: 16px;
}
}
</style>

View File

@@ -1,114 +0,0 @@
<template>
<section>
<h1>Sign in</h1>
<seasoned-input placeholder="username"
icon="Email"
type="email"
@enter="submit"
:value.sync="username" />
<seasoned-input placeholder="password" icon="Keyhole" type="password" :value.sync="password" @enter="submit"/>
<seasoned-button @click="submit">sign in</seasoned-button>
<router-link class="link" to="/register">Don't have a user? Register here</router-link>
<seasoned-messages :messages.sync="messages"></seasoned-messages>
</section>
</template>
<script>
import { login } from '@/api'
import storage from '../storage'
import SeasonedInput from '@/components/ui/SeasonedInput'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedMessages from '@/components/ui/SeasonedMessages'
import { parseJwt } from '@/utils'
export default {
components: { SeasonedInput, SeasonedButton, SeasonedMessages },
data(){
return{
messages: [],
username: null,
password: null
}
},
methods: {
setValue(l, t) {
this[l] = t
},
submit() {
this.messages = [];
let username = this.username;
let password = this.password;
if (username == null || username.length == 0) {
this.messages.push({ type: 'error', title: 'Missing username' })
return
}
if (password == null || password.length == 0) {
this.messages.push({ type: 'error', title: 'Missing password' })
return
}
this.signin(username, password)
},
signin(username, password) {
login(username, password, true)
.then(data => {
if (data.success){
const jwtData = parseJwt(data.token)
localStorage.setItem('token', data.token);
localStorage.setItem('username', jwtData['username']);
localStorage.setItem('admin', jwtData['admin'] || false);
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
})
.catch(error => {
if (error.status === 401) {
this.messages.push({ type: 'error', title: 'Access denied', message: 'Incorrect username or password' })
}
else {
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message })
}
});
}
},
created(){
document.title = 'Sign in' + storage.pageTitlePostfix;
storage.backTitle = document.title;
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
section {
padding: 1.3rem;
@include tablet-min {
padding: 4rem;
}
h1 {
margin: 0;
line-height: 16px;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
.link {
display: block;
width: max-content;
margin-top: 1rem;
}
}
</style>

View File

@@ -1,108 +1,151 @@
<template>
<div v-if="show" class="container">
<h2 class="torrentHeader-text">Searching for: {{ editedSearchQuery || query }}</h2>
<!-- <div class="torrentHeader">
<span class="torrentHeader-text">Searching for:&nbsp;</span>
<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>
<span id="search" :contenteditable="editSearchQuery ? true : false" class="torrentHeader-text editable">{{ editedSearchQuery || query }}</span>
<svg v-if="!editSearchQuery" class="torrentHeader-editIcon" @click="toggleEditSearchQuery">
<use xlink:href="#icon_radar"></use>
</svg>
<svg v-else class="torrentHeader-editIcon" @click="toggleEditSearchQuery">
<use xlink:href="#icon_check"></use>
</svg>
</div> -->
<div v-if="listLoaded">
<div v-if="!loading">
<div v-if="torrents.length > 0">
<!-- <ul class="filter">
<li class="filter-item" v-for="(item, index) in release_types" @click="applyFilter(item, index)" :class="{'active': item === selectedRelaseType}">{{ item }}</li>
</ul> -->
<toggle-button :options="release_types" :selected.sync="selectedRelaseType" class="toggle"></toggle-button>
<table>
<tr class="table__header noselect">
<th @click="sortTable('name')" :class="selectedSortableClass('name')">
<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')">
<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')">
<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>
</tr>
<tr v-for="torrent in torrents" class="table__content">
<td @click="expand($event, torrent.name)">{{ torrent.name }}</td>
<td @click="expand($event, torrent.name)">{{ torrent.seed }}</td>
<td @click="expand($event, torrent.name)">{{ torrent.size }}</td>
<td @click="sendTorrent(torrent.magnet, torrent.name, $event)" class="download">
<svg class="download__icon"><use xlink:href="#iconUnmatched"></use></svg>
</td>
</tr>
</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 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
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">
<div class="editQuery" v-if="editSearchQuery">
<seasonedInput
placeholder="Torrent query"
:value.sync="editedSearchQuery"
@enter="fetchTorrents(editedSearchQuery)"
/>
<seasonedInput placeholder="Torrent query" icon="_torrents" :value.sync="editedSearchQuery" @enter="fetchTorrents(editedSearchQuery)" />
<div style="height: 45px; width: 5px"></div>
<div style="height: 45px; width: 5px;"></div>
<seasonedButton @click="fetchTorrents(editedSearchQuery)"
>Search</seasonedButton
>
</div>
<seasonedButton @click="fetchTorrents(editedSearchQuery)">Search</seasonedButton>
<seasonedButton
@click="toggleEditSearchQuery"
:active="editSearchQuery ? true : false"
>Edit search query</seasonedButton
>
</div>
<seasonedButton @click="toggleEditSearchQuery" :active="editSearchQuery ? true : false">Edit search query</seasonedButton>
</div>
</div>
<div v-else class="torrentloader"><i></i></div>
</div>
</template>
<script>
import storage from '@/storage'
import store from '@/store'
import { sortableSize } from '@/utils'
import { searchTorrents, addMagnet } from '@/api'
import store from "@/store";
import { sortableSize } from "@/utils";
import { searchTorrents, addMagnet } from "@/api";
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedInput from '@/components/ui/SeasonedInput'
import ToggleButton from '@/components/ui/ToggleButton'
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: { SeasonedButton, SeasonedInput, ToggleButton },
components: {
IconMagnet,
IconEdit,
IconSearch,
SeasonedButton,
SeasonedInput,
ToggleButton
},
props: {
query: {
type: String,
@@ -118,175 +161,208 @@ export default {
},
data() {
return {
listLoaded: false,
edit: true,
loading: false,
torrents: [],
torrentResponse: undefined,
currentPage: 0,
prevCol: '',
prevCol: "",
direction: false,
release_types: ['all'],
selectedRelaseType: 'all',
release_types: ["all"],
selectedRelaseType: "all",
editSearchQuery: false,
editedSearchQuery: ''
}
editedSearchQuery: "",
columns: ["name", "seed", "size", "magnet"],
selectedColumn: null
};
},
beforeMount() {
if (localStorage.getItem('admin')) {
this.fetchTorrents()
}
store.dispatch('torrentModule/reset')
created() {
this.fetchTorrents().then(_ => this.sortTable("size"));
},
watch: {
selectedRelaseType: function(newValue) {
this.applyFilter(newValue)
selectedRelaseType: function (newValue) {
this.applyFilter(newValue);
}
},
methods: {
selectedSortableClass(headerName) {
return headerName === this.prevCol ? 'active' : ''
return headerName === this.prevCol ? "active" : "";
},
resetTorrentsAndToggleEditSearchQuery() {
this.torrents = []
this.toggleEditSearchQuery()
this.torrents = [];
this.toggleEditSearchQuery();
},
toggleEditSearchQuery() {
this.editSearchQuery = !this.editSearchQuery;
},
expand(event, name) {
const existingExpandedElement = document.getElementsByClassName('expanded')[0]
const existingExpandedElement =
document.getElementsByClassName("expanded")[0];
const clickedElement = event.target.parentNode;
const scopedStyleDataVariable = Object.keys(clickedElement.dataset)[0]
const scopedStyleDataVariable = Object.keys(clickedElement.dataset)[0];
if (existingExpandedElement) {
const expandedSibling = event.target.parentNode.nextSibling.className === 'expanded'
const expandedSibling =
event.target.parentNode.nextSibling.className === "expanded";
existingExpandedElement.remove()
const table = document.getElementsByTagName('table')[0]
table.style.display = 'block'
existingExpandedElement.remove();
const table = document.getElementsByTagName("table")[0];
table.style.display = "block";
if (expandedSibling) {
return
return;
}
}
const nameRow = document.createElement('tr')
const nameCol = document.createElement('td')
nameRow.className = 'expanded'
const nameRow = document.createElement("tr");
const nameCol = document.createElement("td");
nameRow.className = "expanded";
nameRow.dataset[scopedStyleDataVariable] = "";
nameCol.innerText = name
nameCol.innerText = name;
nameCol.dataset[scopedStyleDataVariable] = "";
nameRow.appendChild(nameCol)
nameRow.appendChild(nameCol);
clickedElement.insertAdjacentElement('afterend', nameRow)
clickedElement.insertAdjacentElement("afterend", nameRow);
},
sendTorrent(magnet, name, event){
sendTorrent(magnet, name, event) {
this.$notifications.info({
title: 'Adding torrent 🦜',
title: "Adding torrent 🦜",
description: this.query,
timeout: 3000
})
});
event.target.parentNode.classList.add('active')
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
.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) {
sortTable(col, sameDirection = false) {
if (this.prevCol === col && sameDirection === false) {
this.direction = !this.direction
this.direction = !this.direction;
}
switch (col) {
case 'name':
this.sortName()
break
case 'seed':
this.sortSeed()
break
case 'size':
this.sortSize()
break
}
this.prevCol = col
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]
sortName() {
const torrentsCopy = [...this.torrents];
if (this.direction) {
this.torrents = torrentsCopy.sort((a, b) => (a.name < b.name) ? 1 : -1)
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)
this.torrents = torrentsCopy.sort((a, b) => (a.name > b.name ? 1 : -1));
}
},
sortSeed() {
const torrentsCopy = [...this.torrents]
sortSeed() {
const torrentsCopy = [...this.torrents];
if (this.direction) {
this.torrents = torrentsCopy.sort((a, b) => parseInt(a.seed) - parseInt(b.seed));
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));
this.torrents = torrentsCopy.sort(
(a, b) => parseInt(b.seed) - parseInt(a.seed)
);
}
},
sortSize() {
const torrentsCopy = [...this.torrents]
sortSize() {
const torrentsCopy = [...this.torrents];
if (this.direction) {
this.torrents = torrentsCopy.sort((a, b) => parseInt(sortableSize(a.size)) - parseInt(sortableSize(b.size)));
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)));
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)]
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]
const torrents = [...this.torrentResponse];
if (item === 'all') {
this.torrents = torrents
this.sortTable(this.prevCol, true)
return
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)
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)
store.dispatch("torrentModule/setResults", this.torrents);
store.dispatch(
"torrentModule/setResultCount",
this.torrentResponse.length
);
},
fetchTorrents(query=undefined){
this.listLoaded = false;
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;
searchTorrents(query || this.query, 'all', this.currentPage, storage.token)
return searchTorrents(query || this.query)
.then(data => {
this.torrentResponse = [...data.results];
this.torrents = data.results;
this.listLoaded = true;
const { results } = data;
if (results) {
this.torrentResponse = results;
this.torrents = this.filterDeadTorrents(results);
} else {
this.torrents = [];
}
})
.then(this.updateResultCountInStore)
.then(this.findRelaseTypes)
.catch(e => {
const error = e.toString()
this.errorMessage = error.indexOf('401') != -1 ? 'Permission denied' : 'Nothing found';
this.listLoaded = true;
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";
@import "src/scss/variables";
.expanded {
display: flex;
padding: 0.25rem 1rem;
@@ -301,11 +377,153 @@ export default {
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";
@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;
@@ -323,17 +541,21 @@ export default {
justify-content: center;
padding-bottom: 20px;
&-text {
font-weight: 400;
text-transform: uppercase;
font-size: 14px;
color: $green;
font-size: 20px;
// color: $green;
text-align: center;
margin: 0;
@include tablet-min {
font-size: 16px
.icon {
vertical-align: text-top;
margin-left: 1rem;
fill: var(--text-color);
width: 22px;
height: 22px;
// stroke: white !important;
}
&.editable {
@@ -355,59 +577,67 @@ export default {
}
table {
border-collapse: collapse;
// border-collapse: collapse;
border-spacing: 0;
margin-top: 1rem;
width: 100%;
table-layout: fixed;
// 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;
// .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%;
// th,
// td {
// display: flex;
// flex-direction: column;
// flex-basis: 100%;
padding: 0.4rem;
// padding: 0.4rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
min-width: 75px;
}
// white-space: nowrap;
// text-overflow: ellipsis;
// overflow: hidden;
// min-width: 75px;
// }
th:first-child, td:first-child {
flex: 1;
}
// th:first-child,
// td:first-child {
// flex: 1;
// }
th:not(:first-child), td:not(:first-child) {
flex: 0.2;
}
// th:not(:first-child),
// td:not(:first-child) {
// flex: 0.2;
// }
th:nth-child(2), td:nth-child(2) {
flex: 0.1;
}
// th:nth-child(2),
// td:nth-child(2) {
// flex: 0.1;
// }
@include mobile-only {
th:first-child, td:first-child {
display: none;
// @include mobile-only {
// th:first-child,
// td:first-child {
// display: none;
&.show {
display: block;
align: flex-end;
}
}
// &.show {
// display: block;
// align: flex-end;
// }
// }
th:not(:first-child), td:not(:first-child) {
flex: 1;
}
}
}
// th:not(:first-child),
// td:not(:first-child) {
// flex: 1;
// }
// }
// }
.table__content {
td:not(:last-child) {
@@ -422,58 +652,54 @@ table {
border-bottom-right-radius: 3px;
}
.table__header {
color: $text-color;
text-transform: uppercase;
cursor: pointer;
background-color: $background-color-secondary;
// .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;
// 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;
// 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;
}
}
// &::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;
@@ -506,7 +732,7 @@ table {
&:after {
border: 5px solid $green;
border-radius: 50%;
content: '';
content: "";
left: 10px;
position: absolute;
top: 16px;
@@ -514,6 +740,8 @@ table {
}
}
@keyframes load {
100% { transform: rotate(360deg); }
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<transition name="shut">
<ul class="dropdown">
<li
v-for="result in searchResults"
:key="`${result.index}-${result.title}-${result.type}`"
@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" />
<span class="title">{{ result.title }}</span>
</li>
<li
v-if="searchResults.length"
:class="`info di-${searchResults.length}`"
>
<span> Select from list or press enter to search </span>
</li>
</ul>
</transition>
</template>
<script>
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";
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
}
},
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;
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";
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;
}
);
},
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
);
}
return filteredResults;
}
},
created() {
if (this.query) this.fetchAutocompleteResults();
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
$sizes: 22;
@for $i from 0 through $sizes {
.dropdown .di-#{$i} {
visibility: visible;
transform-origin: top center;
animation: scaleZ 200ms calc(50ms * #{$i}) ease-in forwards;
}
}
@keyframes scaleZ {
0% {
opacity: 0;
transform: scale(0);
}
80% {
transform: scale(1.07);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.dropdown {
top: var(--header-size);
position: relative;
height: 100%;
width: 100%;
max-width: 720px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
z-index: 5;
margin-top: -1px;
border-top: none;
padding: 0;
@include mobile {
position: fixed;
left: 0;
max-width: 100vw;
}
@include tablet-min {
top: unset;
--gutter: 1.5rem;
max-width: calc(100% - (2 * var(--gutter)));
margin: -1px var(--gutter) 0 var(--gutter);
}
@include desktop {
max-width: 720px;
}
}
li.result {
background-color: var(--background-95);
color: var(--text-color-50);
padding: 0.5rem 2rem;
list-style: none;
opacity: 0;
height: 56px;
width: 100%;
visibility: hidden;
display: flex;
align-items: center;
padding: 0.5rem 2rem;
font-size: 1.4rem;
text-transform: capitalize;
list-style: none;
cursor: pointer;
white-space: nowrap;
transition: color 0.1s ease, fill 0.4s ease;
span {
overflow-x: hidden;
text-overflow: ellipsis;
transition: inherit;
}
&.active,
&:hover,
&:active {
color: var(--text-color);
border-bottom: 2px solid var(--color-green);
.type-icon {
fill: var(--text-color);
}
}
.type-icon {
width: 28px;
height: 28px;
margin-right: 1rem;
transition: inherit;
fill: var(--text-color-50);
}
}
li.info {
visibility: hidden;
opacity: 0;
display: flex;
justify-content: center;
padding: 0 1rem;
color: var(--text-color-50);
background-color: var(--background-95);
color: var(--text-color-50);
font-size: 0.6rem;
height: 16px;
width: 100%;
}
.shut-leave-to {
height: 0px;
transition: height 0.4s ease;
flex-wrap: no-wrap;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<nav>
<a v-if="isHome" class="nav__logo" href="/">
<TmdbLogo class="logo" />
</a>
<router-link v-else class="nav__logo" to="/" exact>
<TmdbLogo class="logo" />
</router-link>
<SearchInput />
<Hamburger class="mobile-only" />
<NavigationIcon class="desktop-only" :route="profileRoute" />
<div class="navigation-icons-grid mobile-only" :class="{ open: isOpen }">
<NavigationIcons>
<NavigationIcon :route="profileRoute" />
</NavigationIcons>
</div>
</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";
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
};
}
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
.spacer {
@include mobile-only {
width: 100%;
height: $header-size;
}
}
nav {
display: grid;
grid-template-columns: var(--header-size) 1fr var(--header-size);
> * {
z-index: 10;
}
}
.nav__logo {
overflow: hidden;
}
.logo {
padding: 1rem;
fill: var(--color-green);
width: var(--header-size);
height: var(--header-size);
display: flex;
align-items: center;
justify-content: center;
background: $background-nav-logo;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.08);
}
@include mobile {
padding: 0.5rem;
}
}
.navigation-icons-grid {
display: flex;
flex-wrap: wrap;
position: fixed;
top: var(--header-size);
left: 0;
width: 100%;
background-color: $background-95;
visibility: hidden;
opacity: 0;
transition: opacity 0.4s ease;
&.open {
opacity: 1;
visibility: visible;
}
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<router-link
:to="{ path: route.route }"
:key="route.title"
v-if="route.requiresAuth == undefined || (route.requiresAuth && loggedIn)"
>
<li class="navigation-link" :class="{ active: route.route == active }">
<component class="navigation-icon" :is="route.icon"></component>
<span>{{ route.title }}</span>
</li>
</router-link>
</template>
<script>
import { mapGetters, mapActions } from "vuex";
export default {
name: "NavigationIcon",
props: {
active: {
type: String,
required: false
},
route: {
type: Object,
required: true
}
},
computed: {
...mapGetters("user", ["loggedIn"])
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
.navigation-link {
display: grid;
place-items: center;
min-height: var(--header-size);
list-style: none;
padding: 1rem 0.15rem;
text-align: center;
background-color: var(--background-color-secondary);
transition: transform 0.3s ease, color 0.3s ease, stoke 0.3s ease,
fill 0.3s ease, background-color 0.5s ease;
&:hover {
transform: scale(1.05);
}
&:hover,
&.active {
background-color: var(--background-color);
span,
.navigation-icon {
color: var(--text-color);
fill: var(--text-color);
}
}
span {
text-transform: uppercase;
font-size: 11px;
margin-top: 0.25rem;
color: var(--text-color-70);
}
}
a {
text-decoration: none;
}
.navigation-icon {
width: 28px;
fill: var(--text-color-70);
transition: inherit;
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<ul class="navigation-icons">
<NavigationIcon
v-for="route in routes"
:key="route.route"
:route="route"
:active="activeRoute"
/>
<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";
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;
}
},
created() {
this.activeRoute = window.location.pathname;
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
.navigation-icons {
display: grid;
grid-column: 1fr;
padding-left: 0;
margin: 0;
background-color: var(--background-color-secondary);
z-index: 15;
width: 100%;
@include desktop {
grid-template-rows: var(--header-size);
}
@include mobile {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div>
<div class="search" :class="{ active: focusingInput }">
<IconSearch class="search-icon" tabindex="-1" />
<input
ref="input"
type="text"
placeholder="Search for movie or show"
aria-label="Search input for finding a movie or show"
autocorrect="off"
autocapitalize="off"
tabindex="0"
v-model="query"
@input="handleInput"
@click="focusingInput = true"
@keydown.escape="handleEscape"
@keyup.enter="handleSubmit"
@keydown.up="navigateUp"
@keydown.down="navigateDown"
@focus="focusingInput = true"
@blur="focusingInput = false"
/>
<IconClose
tabindex="0"
aria-label="button"
v-if="query && query.length"
@click="resetQuery"
@keydown.enter.stop="resetQuery"
class="close-icon"
/>
</div>
<AutocompleteDropdown
v-if="showAutocompleteResults"
: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";
import IconSearch from "src/icons/IconSearch";
import IconClose from "src/icons/IconClose";
import config from "@/config";
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 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 input = this.$refs.input;
const textLength = input.value.length;
setTimeout(() => {
input.focus();
input.setSelectionRange(textLength, textLength + 1);
}, 1);
},
search() {
const encodedQuery = encodeURI(this.query.replace('/ /g, "+"'));
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;
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;
}
}
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
.close-icon {
position: absolute;
top: calc(50% - 12px);
right: 0;
cursor: pointer;
fill: var(--text-color);
height: 24px;
width: 24px;
@include tablet-min {
right: 6px;
}
}
.filter {
width: 100%;
display: flex;
flex-direction: column;
margin: 1rem 2rem;
h2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 400;
}
&-items {
display: flex;
flex-direction: row;
align-items: center;
> :not(:first-child) {
margin-left: 1rem;
}
}
}
hr {
display: block;
height: 1px;
border: 0;
border-bottom: 1px solid $text-color-50;
margin-top: 10px;
margin-bottom: 10px;
width: 90%;
}
.search.active {
input {
border-color: var(--color-green);
}
.search-icon {
fill: var(--color-green);
}
}
.search {
height: $header-size;
display: flex;
position: fixed;
flex-wrap: wrap;
z-index: 5;
border: 0;
background-color: $background-color-secondary;
// TODO check if this is for mobile
width: calc(100% - 110px);
top: 0;
right: 55px;
@include tablet-min {
position: relative;
width: 100%;
right: 0px;
}
input {
display: block;
width: 100%;
padding: 13px 28px 13px 45px;
outline: none;
margin: 0;
border: 0;
background-color: $background-color-secondary;
font-weight: 300;
font-size: 18px;
color: $text-color;
border-bottom: 1px solid transparent;
&:focus {
// border-bottom: 1px solid var(--color-green);
border-color: var(--color-green);
}
@include tablet-min {
font-size: 24px;
padding: 13px 40px 13px 60px;
}
}
&-icon {
width: 20px;
height: 20px;
fill: var(--text-color-50);
pointer-events: none;
position: absolute;
left: 15px;
top: calc(50% - 10px);
@include tablet-min {
width: 24px;
height: 24px;
top: calc(50% - 12px);
left: 22px;
}
}
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<li
class="sidebar-list-element"
@click="event => $emit('click', event)"
:class="{ active, disabled }"
>
<slot></slot>
</li>
</template>
<script>
export default {
props: {
active: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
}
};
</script>
<style lang="scss">
@import "src/scss/media-queries";
li.sidebar-list-element {
display: flex;
align-items: center;
text-decoration: none;
text-transform: uppercase;
color: var(--text-color-50);
font-size: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--text-color-5);
cursor: pointer;
&:first-of-type {
padding-top: 0;
}
div > svg,
svg {
width: 26px;
height: 26px;
margin-right: 1rem;
transition: all 0.3s ease;
fill: var(--text-color-70);
}
&:hover,
&.active {
color: var(--text-color);
div > svg,
svg {
fill: var(--text-color);
transform: scale(1.1, 1.1);
}
}
&.active > div > svg,
&.active > svg {
fill: var(--color-green);
}
&.disabled {
cursor: default;
}
.pending {
color: #f8bd2d;
}
.meta {
margin-left: auto;
text-align: right;
}
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div
id="description"
class="movie-description noselect"
@click="overflow ? (truncated = !truncated) : null"
>
<span ref="description" :class="{ truncated }">{{ description }}</span>
<button v-if="description && overflow" class="truncate-toggle">
<IconArrowDown :class="{ rotate: !truncated }" />
</button>
</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;
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())
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
.movie-description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
transition: all 1s ease;
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
}
span.truncated {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.truncate-toggle {
border: none;
background: none;
width: 100%;
display: flex;
align-items: center;
text-align: center;
color: var(--text-color);
margin-top: 1rem;
cursor: pointer;
svg {
transition: 0.4s ease all;
height: 22px;
width: 22px;
fill: var(--text-color);
&.rotate {
transform: rotateX(180deg);
}
}
&::before,
&::after {
content: "";
flex: 1;
border-bottom: 1px solid var(--text-color-50);
}
&::before {
margin-right: 1rem;
}
&::after {
margin-left: 1rem;
}
}
</style>

View File

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

View File

@@ -10,12 +10,15 @@
<img
class="movie-item__img is-loaded"
ref="poster-image"
src="~assets/placeholder.png"
src="/assets/placeholder.png"
/>
</figure>
<h1 class="movie__title" v-if="movie">{{ movie.title }}</h1>
<loading-placeholder v-else :count="1" />
<div v-if="movie" class="movie__title">
<h1>{{ movie.title || movie.name }}</h1>
<i>{{ movie.tagline }}</i>
</div>
<loading-placeholder v-else :count="2" />
</header>
<!-- Siderbar and movie info -->
@@ -23,50 +26,58 @@
<div class="movie__wrap movie__wrap--main">
<!-- SIDEBAR ACTIONS -->
<div class="movie__actions" v-if="movie">
<sidebar-list-element
:iconRef="'#iconNot_exsits'"
:active="matched"
:iconRefActive="'#iconExists'"
:textActive="'Already in plex 🎉'"
>
Not yet in plex
</sidebar-list-element>
<sidebar-list-element
@click="sendRequest"
:iconRef="'#iconSent'"
:active="requested"
:textActive="'Requested to be downloaded'"
>
Request to be downloaded?
</sidebar-list-element>
<action-button :active="matched" :disabled="true">
<IconThumbsUp v-if="matched" />
<IconThumbsDown v-else />
{{ !matched ? "Not yet available" : "Already available 🎉" }}
</action-button>
<sidebar-list-element
v-if="isPlexAuthenticated && matched"
@click="openInPlex"
:iconString="'⏯ '"
>
Watch in plex now!
</sidebar-list-element>
<action-button @click="sendRequest" :active="requested">
<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" }}
</action-button>
<sidebar-list-element
v-if="admin"
<action-button v-if="plexId && matched" @click="openInPlex">
<IconPlay />
Open and watch in plex now!
</action-button>
<action-button
v-if="credits && credits.cast && credits.cast.length"
:active="showCast"
@click="() => (showCast = !showCast)"
>
<IconProfile class="icon" />
{{ showCast ? "Hide cast" : "Show cast" }}
</action-button>
<action-button
v-if="admin === true"
@click="showTorrents = !showTorrents"
:iconRef="'#icon_torrents'"
:active="showTorrents"
:supplementaryText="numberOfTorrentResults"
>
<IconBinoculars />
Search for torrents
</sidebar-list-element>
<sidebar-list-element @click="openTmdb" :iconRef="'#icon_info'">
<span v-if="numberOfTorrentResults" class="meta">{{
numberOfTorrentResults
}}</span>
</action-button>
<action-button @click="openTmdb">
<IconInfo />
See more info
</sidebar-list-element>
</action-button>
</div>
<!-- Loading placeholder -->
<div class="movie__actions text-input__loading" v-else>
<div
v-for="index in admin ? Array(4) : Array(3)"
class="movie__actions-link"
v-for="_ in admin ? Array(4) : Array(3)"
:key="index"
>
<div
class="movie__actions-text text-input__loading--line"
@@ -78,70 +89,63 @@
<!-- MOVIE INFO -->
<div class="movie__info">
<!-- Loading placeholder -->
<div
class="movie__description noselect"
@click="truncatedDescription = !truncatedDescription"
v-if="!loading"
>
<span :class="truncatedDescription ? 'truncated' : null">{{
movie.overview
}}</span>
<button class="truncate-toggle"><i></i></button>
</div>
<div v-else class="movie__description">
<div v-if="loading">
<loading-placeholder :count="5" />
</div>
<Description
v-if="!loading && movie && movie.overview"
:description="movie.overview"
/>
<div class="movie__details" v-if="movie">
<div v-if="movie.year">
<h2 class="title">Release Date</h2>
<div class="text">{{ movie.year }}</div>
</div>
<div v-if="movie.rating">
<h2 class="title">Rating</h2>
<div class="text">{{ movie.rating }}</div>
</div>
<div v-if="movie.type == 'show'">
<h2 class="title">Seasons</h2>
<div class="text">{{ movie.seasons }}</div>
</div>
<div v-if="movie.genres">
<h2 class="title">Genres</h2>
<div class="text">{{ movie.genres.join(", ") }}</div>
</div>
<div v-if="movie.type == 'show'">
<h2 class="title">Production status</h2>
<div class="text">{{ movie.production_status }}</div>
</div>
<div v-if="movie.type == 'show'">
<h2 class="title">Runtime</h2>
<div class="text">{{ movie.runtime[0] }} minutes</div>
</div>
<Detail
v-if="movie.year"
title="Release date"
:detail="movie.year"
/>
<Detail v-if="movie.rating" title="Rating" :detail="movie.rating" />
<Detail
v-if="movie.type == 'show'"
title="Seasons"
:detail="movie.seasons"
/>
<Detail
v-if="movie.genres && movie.genres.length"
title="Genres"
:detail="movie.genres.join(', ')"
/>
<Detail
v-if="
movie.production_status &&
movie.production_status !== 'Released'
"
title="Production status"
:detail="movie.production_status"
/>
<Detail
v-if="movie.runtime"
title="Runtime"
:detail="humanMinutes(movie.runtime)"
/>
</div>
</div>
<!-- TODO: change this classname, this is general -->
<div class="movie__admin" v-if="movie && movie.credits">
<h2 class="movie__details-title">Cast</h2>
<div style="display: flex; flex-wrap: wrap">
<person
v-for="cast in movie.credits.cast"
:info="cast"
style="flex-basis: 0"
></person>
</div>
<div
class="movie__admin"
v-if="showCast && credits && credits.cast && credits.cast.length"
>
<Detail title="cast">
<CastList :cast="credits.cast" />
</Detail>
</div>
</div>
<!-- TORRENT LIST -->
<TorrentList
v-if="movie"
v-if="movie && admin"
:show="showTorrents"
:query="title"
:tmdb_id="id"
@@ -152,18 +156,29 @@
</template>
<script>
import storage from "@/storage";
import { mapGetters } from "vuex";
import img from "@/directives/v-image";
import TorrentList from "./TorrentList";
import Person from "./Person";
import SidebarListElement from "./ui/sidebarListElem";
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 "./ui/LoadingPlaceholder";
import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder";
import {
getMovie,
getPerson,
getShow,
getPerson,
getCredits,
request,
getRequestStatus,
watchLink
@@ -181,7 +196,22 @@ export default {
type: String
}
},
components: { TorrentList, Person, LoadingPlaceholder, SidebarListElement },
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 {
@@ -192,22 +222,17 @@ export default {
poster: undefined,
backdrop: undefined,
matched: false,
userLoggedIn: storage.sessionId ? true : false,
requested: false,
admin: localStorage.getItem("admin") == "true" ? true : false,
showTorrents: false,
showCast: false,
credits: [],
compact: false,
loading: true,
truncatedDescription: true
loading: true
};
},
watch: {
id: function (val) {
if (this.type === "movie") {
this.fetchMovie(val);
} else {
this.fetchShow(val);
}
this.fetchByType();
},
backdrop: function (backdrop) {
if (backdrop != null) {
@@ -221,15 +246,32 @@ export default {
}
},
computed: {
...mapGetters("user", ["loggedIn", "admin", "plexId"]),
numberOfTorrentResults: () => {
let numTorrents = store.getters["torrentModule/resultCount"];
return numTorrents !== null ? numTorrents + " results" : null;
},
isPlexAuthenticated: () => {
return store.getters["userModule/isPlexAuthenticated"];
}
},
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 };
@@ -248,21 +290,37 @@ export default {
setPosterSrc() {
const poster = this.$refs["poster-image"];
if (this.poster == null) {
poster.src = "/no-image.png";
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, storage.token).then(resp => {
request(this.id, this.type).then(resp => {
if (resp.success) {
this.requested = true;
}
});
},
openInPlex() {
watchLink(this.title, this.movie.year, storage.token).then(
watchLink(this.title, this.movie.year).then(
watchLink => (window.location = watchLink)
);
},
@@ -273,27 +331,9 @@ export default {
}
},
created() {
store.dispatch("torrentModule/setResultCount", null);
this.prevDocumentTitle = store.getters["documentTitle/title"];
if (this.type === "movie") {
getMovie(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
} else if (this.type == "person") {
getPerson(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
} else {
getShow(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
}
this.fetchByType();
},
beforeDestroy() {
store.dispatch("documentTitle/updateTitle", this.prevDocumentTitle);
@@ -302,14 +342,13 @@ export default {
</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;
height: 250px;
transform: scaleY(1);
transition: height $duration ease;
transform-origin: top;
@@ -318,23 +357,32 @@ header {
background-repeat: no-repeat;
background-position: 50% 50%;
background-color: $background-color;
display: flex;
align-items: center;
display: grid;
grid-template-columns: 1fr 1fr;
height: 350px;
@include tablet-min {
height: 350px;
@include mobile {
grid-template-columns: 1fr;
height: 250px;
place-items: center;
}
&:before {
* {
z-index: 2;
}
&::before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 0;
z-index: 1;
width: 100%;
height: 100%;
background: $background-dark-85;
}
@include mobile {
&.compact {
height: 100px;
@@ -346,53 +394,21 @@ header {
display: none;
@include desktop {
background: $background-color;
height: 0;
background: var(--background-color);
height: auto;
display: block;
position: absolute;
width: calc(45% - 40px);
top: 40px;
left: 40px;
width: calc(100% - 80px);
margin: 40px;
> img {
width: 100%;
border-radius: 10px;
}
}
}
.truncate-toggle {
border: none;
background: none;
width: 100%;
display: flex;
align-items: center;
text-align: center;
color: $text-color;
> i {
font-style: unset;
font-size: 0.7rem;
transition: 0.3s ease all;
transform: rotateY(180deg);
}
&::before,
&::after {
content: "";
flex: 1;
border-bottom: 1px solid $text-color-50;
}
&::before {
margin-right: 1rem;
}
&::after {
margin-left: 1rem;
}
}
.movie {
&__wrap {
display: flex;
&--header {
align-items: center;
height: 100%;
@@ -426,32 +442,33 @@ header {
&__title {
position: relative;
padding: 20px;
color: $green;
text-align: center;
width: 100%;
height: fit-content;
@include tablet-min {
width: 55%;
text-align: left;
margin-left: 45%;
padding: 30px 30px 30px 40px;
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;
}
}
}
&__main {
min-height: calc(100vh - 250px);
@include tablet-min {
min-height: 0;
}
height: 100%;
i {
display: block;
color: rgba(255, 255, 255, 0.8);
margin-top: 1rem;
}
}
&__actions {
text-align: center;
width: 100%;
@@ -479,53 +496,15 @@ header {
&__info {
margin-left: 0;
}
&__description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
& .truncated {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
& + .truncate-toggle > i {
transform: rotateY(0deg) rotateZ(180deg);
}
}
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
}
&__details {
display: flex;
flex-wrap: wrap;
> div {
margin-bottom: 20px;
margin-right: 20px;
@include tablet-min {
margin-bottom: 30px;
margin-right: 30px;
}
& .title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
font-size: 14px;
color: $green;
@include tablet-min {
font-size: 16px;
}
}
& .text {
font-weight: 300;
font-size: 14px;
margin-top: 5px;
> * {
margin-right: 30px;
@include mobile {
margin-right: 20px;
}
}
}
@@ -553,4 +532,13 @@ header {
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<section class="person">
<header ref="header">
<div class="info">
<h1 v-if="person">
{{ person.title || person.name }}
</h1>
<div v-else>
<loading-placeholder :count="1" />
<loading-placeholder :count="1" lineClass="short" :top="3.5" />
</div>
<span class="known-for" v-if="person && person['known_for_department']">
{{
person.known_for_department === "Acting"
? "Actor"
: person.known_for_department
}}
</span>
</div>
<figure class="person__poster">
<img
class="person-item__img is-loaded"
ref="poster-image"
src="/assets/placeholder.png"
/>
</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 />
</div>
<div v-if="person">
<Detail v-if="age" title="Age" :detail="age" />
<Detail
v-if="person"
title="Born"
:detail="person.place_of_birth ? person.place_of_birth : '(Not found)'"
/>
<Detail v-if="person.biography" title="Biography">
<Description :description="person.biography" />
</Detail>
<Detail
title="movies"
:detail="`Credited in ${movieCredits.length} movies`"
v-if="credits"
>
<CastList :cast="movieCredits" />
</Detail>
<Detail
title="shows"
:detail="`Credited in ${showCredits.length} shows`"
v-if="credits"
>
<CastList :cast="showCredits" />
</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";
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);
});
}
};
</script>
<style lang="scss" scoped>
@import "src/scss/loading-placeholder";
@import "src/scss/variables";
@import "src/scss/media-queries";
@import "src/scss/main";
section.person {
overflow: hidden;
position: relative;
padding: 40px;
background-color: var(--background-color);
@include mobile {
padding: 50px 20px 10px;
}
&:before {
content: "";
display: block;
position: absolute;
top: -130px;
left: -100px;
z-index: 1;
width: 1000px;
height: 500px;
transform: rotate(21deg);
background-color: #062541;
@include mobile {
// top: -52vw;
top: -215px;
}
}
}
header {
$duration: 0.2s;
transition: height $duration ease;
position: relative;
background-color: transparent;
display: grid;
grid-template-columns: 1fr 1fr;
height: 350px;
z-index: 2;
@include mobile {
height: 180px;
}
.info {
display: flex;
flex-direction: column;
padding: 30px;
padding-left: 0;
text-align: left;
@include mobile {
padding: 0;
}
}
h1 {
color: $green;
width: 100%;
font-weight: 500;
line-height: 1.4;
font-size: 30px;
margin-top: 0;
@include mobile {
font-size: 24px;
margin: 10px 0;
// padding: 30px 30px 30px 40px;
}
}
.known-for {
color: rgba(255, 255, 255, 0.8);
font-size: 1.2rem;
}
}
.person__poster {
display: block;
border-radius: 10px;
background-color: grey;
animation: pulse 1s infinite ease-in-out;
@keyframes pulse {
0% {
background-color: rgba(165, 165, 165, 0.1);
}
50% {
background-color: rgba(165, 165, 165, 0.3);
}
100% {
background-color: rgba(165, 165, 165, 0.1);
}
}
> img {
border-radius: 10px;
width: 100%;
@include mobile {
max-width: 225px;
}
}
}
</style>

View File

@@ -1,49 +1,44 @@
<template>
<div class="darkToggle">
<span @click="toggleDarkmode()">{{ darkmodeToggleIcon }}</span>
<span @click="toggleDarkmode">{{ darkmodeToggleIcon }}</span>
</div>
</template>
<script>
export default {
data() {
return {
darkmode: this.supported
}
darkmode: this.systemDarkModeEnabled()
};
},
methods: {
toggleDarkmode() {
this.darkmode = !this.darkmode;
document.body.className = this.darkmode ? 'dark' : 'light'
document.body.className = this.darkmode ? "dark" : "light";
},
supported() {
const computedStyle = window.getComputedStyle(document.body)
if (computedStyle['colorScheme'] != null)
return computedStyle.colorScheme.includes('dark')
return false
systemDarkModeEnabled() {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle["colorScheme"] != null) {
return computedStyle.colorScheme.includes("dark");
}
return false;
}
},
computed: {
darkmodeToggleIcon() {
return this.darkmode ? '🌝' : '🌚'
return this.darkmode ? "🌝" : "🌚";
}
}
}
};
</script>
<style lang="scss" scoped>
.darkToggle {
height: 25px;
width: 25px;
cursor: pointer;
// background-color: red;
position: fixed;
margin-bottom: 10px;
margin-bottom: 1.5rem;
margin-right: 2px;
bottom: 0;
right: 0;
@@ -54,4 +49,4 @@ export default {
-ms-user-select: none;
user-select: none;
}
</style>
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div
class="nav__hamburger"
:class="{ open: isOpen }"
@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";
export default {
computed: { ...mapGetters("hamburger", ["isOpen"]) },
methods: { ...mapActions("hamburger", ["toggle"]) }
};
</script>
<style lang="scss" scoped>
@import "src/scss/media-queries";
.nav__hamburger {
display: block;
position: relative;
width: var(--header-size);
height: var(--header-size);
cursor: pointer;
border-left: 1px solid var(--background-color);
background-color: var(--background-color-secondary);
@include tablet-min {
display: none;
}
.bar {
position: absolute;
width: 23px;
height: 1px;
background-color: var(--text-color-70);
transition: all 300ms ease;
&:nth-child(1) {
left: 16px;
top: 17px;
}
&:nth-child(2) {
left: 16px;
top: 25px;
&:after {
content: "";
position: absolute;
left: 0px;
top: 0px;
width: 23px;
height: 1px;
transition: all 300ms ease;
}
}
&:nth-child(3) {
right: 15px;
top: 33px;
}
}
&.open {
.bar {
&:nth-child(1),
&:nth-child(3) {
width: 0;
}
&:nth-child(2) {
transform: rotate(-45deg);
}
&:nth-child(2):after {
transform: rotate(-90deg);
background-color: var(--text-color-70);
}
}
}
}
</style>

View File

@@ -7,7 +7,7 @@
</template>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "src/scss/variables";
.loader {
display: flex;
@@ -16,7 +16,7 @@
justify-content: center;
align-items: center;
&--icon{
&--icon {
border: 2px solid $text-color-70;
border-radius: 50%;
display: block;
@@ -32,7 +32,7 @@
&:after {
border: 7px solid $green-90;
border-radius: 50%;
content: '';
content: "";
left: 8px;
position: absolute;
top: 22px;
@@ -40,7 +40,9 @@
}
}
@keyframes load {
100% { transform: rotate(360deg); }
100% {
transform: rotate(360deg);
}
}
}
</style>
</style>

View File

@@ -1,8 +1,11 @@
<template>
<div>
<div class="text-input__loading">
<div class="text-input__loading--line" :class="lineClass" v-for="_ in Array(count)"></div>
</div>
<div class="text-input__loading" :style="`margin-top: ${top}rem`">
<div
class="text-input__loading--line"
:class="lineClass"
v-for="l in Array(count)"
:key="l"
></div>
</div>
</template>
@@ -11,17 +14,21 @@ export default {
props: {
count: {
type: Number,
require: true
default: 1,
require: false
},
lineClass: {
type: String,
default: ''
default: ""
},
top: {
type: Number,
default: 0
}
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/loading-placeholder";
</style>
@import "src/scss/loading-placeholder";
</style>

View File

@@ -1,31 +1,39 @@
<template>
<button type="button" @click="emit('click')" :class="{ active: active }">
<button
type="button"
@click="emit('click')"
:class="{ active: active, fullwidth: fullWidth }"
>
<slot></slot>
</button>
</template>
<script>
export default {
name: 'seasonedButton',
name: "seasonedButton",
props: {
active: {
type: Boolean,
default: false,
required: false
},
fullWidth: {
type: Boolean,
default: false,
required: false
}
},
methods: {
emit() {
this.$emit('click')
this.$emit("click");
}
}
}
};
</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;
@@ -43,14 +51,25 @@ button {
background: $background-color-secondary;
cursor: pointer;
outline: none;
transition: background 0.5s ease, color 0.5s ease, border-color .5s ease;
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;
}
&:focus, &:active, &.active {
&.fullwidth {
font-size: 14px;
width: 40%;
@include mobile {
width: 60%;
}
}
&:focus,
&:active,
&.active {
background: $text-color;
color: $background-color;
}

View File

@@ -1,116 +1,134 @@
<template>
<div class="group" :class="{ completed: value }">
<svg class="group__input-icon"><use v-bind="{'xlink:href':'#icon' + icon}"></use></svg>
<input class="group__input" :type="tempType || type" @input="handleInput" v-model="inputValue"
:placeholder="placeholder" @keyup.enter="submit" />
<i v-if="value && type === 'password'" @click="toggleShowPassword" class="group__input-show noselect">show</i>
<div class="group" :class="{ completed: value, focus }">
<component :is="inputIcon" v-if="inputIcon" />
<input
class="input"
:type="tempType || type"
@input="handleInput"
v-model="inputValue"
:placeholder="placeholder"
@keyup.enter="event => $emit('enter', event)"
@focus="focus = true"
@blur="focus = false"
/>
<i
v-if="value && type === 'password'"
@click="toggleShowPassword"
@keydown.enter="toggleShowPassword"
class="show noselect"
tabindex="0"
>{{ tempType == "password" ? "show" : "hide" }}</i
>
</div>
</template>
<script>
import IconKey from "../../icons/IconKey";
import IconEmail from "../../icons/IconEmail";
export default {
components: { IconKey, IconEmail },
props: {
placeholder: { type: String },
icon: { type: String },
type: { type: String, default: 'text' },
type: { type: String, default: "text" },
value: { type: String, default: undefined }
},
data() {
return {
inputValue: this.value || undefined,
tempType: undefined
tempType: this.type,
focus: false
};
},
computed: {
inputIcon() {
if (this.type === "password") return IconKey;
if (this.type === "email") return IconEmail;
return false;
}
},
methods: {
submit(event) {
this.$emit('enter')
},
handleInput(event) {
if (this.value !== undefined) {
this.$emit('update:value', this.inputValue)
this.$emit("update:value", this.inputValue);
} else {
this.$emit('change', this.inputValue, event)
this.$emit("change", this.inputValue, event);
}
},
toggleShowPassword() {
if (this.tempType === 'text') {
this.tempType = 'password'
if (this.tempType === "text") {
this.tempType = "password";
} else {
this.tempType = 'text'
this.tempType = "text";
}
}
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "src/scss/variables";
@import "src/scss/media-queries";
.group{
.group {
display: flex;
margin-bottom: 1rem;
width: 100%;
position: relative;
max-width: 35rem;
border: 1px solid var(--text-color-50);
background-color: var(--background-color-secondary);
&:hover, &:focus {
.group__input {
border-color: $text-color;
&.completed,
&.focus,
&:hover,
&:focus {
border-color: var(--text-color);
&-icon {
fill: $text-color;
}
svg {
fill: var(--text-color);
}
}
&.completed {
.group__input {
border-color: $text-color;
&-icon {
fill: $text-color;
}
}
}
&__input {
width: 100%;
max-width: 35rem;
padding: 10px 10px 10px 45px;
outline: none;
background-color: $background-color-secondary;
color: $text-color;
font-weight: 100;
font-size: 1.2rem;
border: 1px solid $text-color-50;
margin: 0;
margin-left: -2.2rem !important;
z-index: 3;
transition: color .5s ease, background-color .5s ease, border .5s ease;
border-radius: 0;
-webkit-appearance: none;
&-show {
position: relative;
left: -50px;
z-index: 11;
margin: auto 0;
height: 100%;
font-size: 0.9rem;
cursor: pointer;
color: $text-color-50;
}
}
&__input-icon {
svg {
width: 24px;
height: 24px;
fill: $text-color-50;
transition: fill 0.5s ease;
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>
</style>

View File

@@ -1,10 +1,19 @@
<template>
<transition-group name="fade">
<div class="message" v-for="(message, index) in reversedMessages" :class="message.type || 'warning'" :key="index">
<div
class="message"
v-for="(message, index) in reversedMessages"
:key="`${index}-${message.title}-${message.type}}`"
:class="message.type || 'warning'"
>
<span class="pinstripe"></span>
<div>
<h2 class="title">{{ message.title || defaultTitles[message.type] }}</h2>
<span v-if="message.message" class="message">{{ message.message }}</span>
<h2 class="title">
{{ message.title || defaultTitles[message.type] }}
</h2>
<span v-if="message.message" class="message">{{
message.message
}}</span>
</div>
<button class="dismiss" @click="clicked(message)">X</button>
@@ -13,7 +22,6 @@
</template>
<script>
export default {
props: {
messages: {
@@ -24,37 +32,36 @@ export default {
data() {
return {
defaultTitles: {
error: 'Unexpected error',
warning: 'Something went wrong',
undefined: 'Something went wrong'
error: "Unexpected error",
warning: "Something went wrong",
undefined: "Something went wrong"
},
localMessages: [...this.messages]
}
};
},
computed: {
reversedMessages() {
return [...this.messages].reverse()
return [...this.messages].reverse();
}
},
methods: {
clicked(e) {
const removedMessage = [...this.messages].filter(mes => mes !== e)
this.$emit('update:messages', removedMessage)
const removedMessage = [...this.messages].filter(mes => mes !== e);
this.$emit("update:messages", removedMessage);
}
}
}
};
</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 .4s;
transition: opacity 0.4s;
}
.fade-leave-active {
transition: opacity .1s;
transition: opacity 0.1s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
@@ -72,7 +79,6 @@ export default {
> div {
margin: 10px 24px;
width: 100%;
}
.title {
@@ -81,12 +87,12 @@ export default {
margin: 0;
font-size: 1.3rem;
color: $text-color;
transition: color .5s ease;
transition: color 0.5s ease;
}
.message {
font-weight: 300;
color: $text-color-70;
transition: color .5s ease;
transition: color 0.5s ease;
margin: 0.2rem 0 0.5rem;
}
@@ -101,7 +107,6 @@ export default {
span {
font-size: 0.9rem;
}
}
.pinstripe {
@@ -126,7 +131,7 @@ export default {
margin-top: 0.5rem;
margin-right: 0.5rem;
color: $text-color-70;
transition: color .5s ease;
transition: color 0.5s ease;
&:hover {
color: $text-color;
@@ -140,7 +145,7 @@ export default {
background-color: $color-success-highlight;
}
}
&.error {
background-color: $color-error;
@@ -151,11 +156,10 @@ export default {
&.warning {
background-color: $color-warning;
.pinstripe {
background-color: $color-warning-highlight;
}
}
}
</style>
</style>

View File

@@ -1,9 +0,0 @@
<template>
<div v-html="require(`@/assets/icons/${ icon }.svg`)"></div>
</template>
<script>
export default {
props: ['icon']
}
</script>

View File

@@ -1,14 +1,19 @@
<template>
<div class="toggle-container">
<button v-for="option in options" class="toggle-button" @click="toggle(option)"
<button
v-for="option in options"
:key="option"
class="toggle-button"
@click="toggle(option)"
:class="toggleValue === option ? 'selected' : null"
>{{ option }}</button>
>
{{ option }}
</button>
</div>
</template>
<script>
export default {
export default {
props: {
options: {
Array,
@@ -23,38 +28,35 @@ export default {
data() {
return {
toggleValue: this.selected || this.options[0]
}
},
beforeMount() {
this.toggle(this.toggleValue)
};
},
methods: {
toggle(toggleValue) {
this.toggleValue = toggleValue;
if (this.selected !== undefined) {
this.$emit('update:selected', toggleValue)
this.$emit("update:selected", toggleValue);
this.$emit("change", toggleValue);
} else {
this.$emit('change', toggleValue)
this.$emit("change", toggleValue);
}
}
},
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "src/scss/variables";
$background: $background-ui;
$background-selected: $background-color-secondary;
.toggle-container {
width: 100%;
max-width: 15rem;
display: flex;
overflow-x: scroll;
flex-direction: row;
justify-content: center;
align-items: center;
// padding: 0.2rem;
background-color: $background;
border: 2px solid $background;
border-radius: 8px;
@@ -65,36 +67,20 @@ $background-selected: $background-color-secondary;
font-size: 1rem;
line-height: 1rem;
font-weight: normal;
width: 100%;
padding: 0.5rem 0;
padding: 0.5rem;
border: 0;
color: $text-color;
// background-color: $text-color-5;
background-color: $background;
text-transform: capitalize;
cursor: pointer;
display: block;
flex: 1 0 auto;
&.selected {
color: $text-color;
// background-color: $background-color-secondary;
background-color: $background-selected;
border-radius: 8px;
}
// &:first-of-type, &:last-of-type {
// border-left: 4px solid $background;
// border-right: 4px solid $background;
// }
// &:first-of-type {
// border-top-left-radius: 4px;
// border-bottom-left-radius: 4px;
// }
// &:last-of-type {
// border-top-right-radius: 4px;
// border-bottom-right-radius: 4px;
// }
}
}
</style>
</style>

View File

@@ -1,138 +0,0 @@
<template>
<div>
<a @click="$emit('click')">
<li>
<figure v-if="iconRef" :class="activeClassIfActive">
<svg class="icon"><use :xlink:href="iconRefNameIfActive"/></svg>
</figure>
<span class="text" :class="activeClassIfActive">{{ contentTextToDisplay }}</span>
<span v-if="supplementaryText" class="supplementary-text">
{{ supplementaryText }}
</span>
</li>
</a>
</div>
</template>
<script>
// TODO if a image is hovered and we can't set the hover color we want to
// go into it and change the fill
export default {
props: {
iconRef: {
type: String,
required: false
},
iconRefActive: {
type: String,
required: false
},
active: {
type: Boolean,
default: false,
},
textActive: {
type: String,
required: false
},
supplementaryText: {
type: String,
required: false
}
},
computed: {
iconRefNameIfActive() {
const { iconRefActive, iconRef, active } = this
if ((iconRefActive && iconRef) && active) {
return iconRefActive
}
return iconRef
},
contentTextToDisplay() {
const { textActive, active, $slots } = this
if (textActive && active)
return textActive
if ($slots.default && $slots.default.length > 0)
return $slots.default[0].text
return ''
},
activeClassIfActive() {
return this.active ? 'active' : ''
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
li {
display: flex;
align-items: center;
text-decoration: none;
text-transform: uppercase;
color: $text-color-50;
transition: color 0.5s ease;
font-size: 11px;
padding: 10px 0;
border-bottom: 1px solid $text-color-5;
&:hover {
color: $text-color;
cursor: pointer;
.icon {
fill: $text-color;
cursor: pointer;
transform: scale(1.1, 1.1);
}
}
.active {
color: $text-color;
.icon {
fill: $green;
}
}
.pending {
color: #f8bd2d;
}
.text {
margin-left: 26px;
}
.supplementary-text {
flex-grow: 1;
text-align: right;
}
figure {
position: absolute;
> svg {
position: relative;
top: 50%;
width: 16px;
height: 16px;
margin: 0 7px 0 0;
fill: $text-color-50;
transition: fill 0.5s ease, transform 0.5s ease;
& .waiting {
transform: scale(0.8, 0.8);
}
& .pending {
fill: #f8bd2d;
}
}
}
}
</style>

9
src/config.ts Normal file
View File

@@ -0,0 +1,9 @@
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

@@ -0,0 +1,16 @@
<template>
<svg
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>
</template>

View File

@@ -0,0 +1,8 @@
<template>
<svg 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>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg 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>
</template>

15
src/icons/IconClose.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<svg
id="icon-cross"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
@click="$emit('click')"
@keydown="event => $emit('keydown', event)"
style="transition-duration: 0s;"
>
<path
fill="inherit"
d="M27.942 5.942l-1.883-1.883-10.058 10.054-10.058-10.054-1.883 1.883 10.054 10.058-10.054 10.058 1.883 1.883 10.058-10.054 10.058 10.054 1.883-1.883-10.054-10.058z"
></path>
</svg>
</template>

12
src/icons/IconEdit.vue Normal file
View File

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

8
src/icons/IconEmail.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<svg 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>
</template>

22
src/icons/IconExpand.vue Normal file
View File

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

35
src/icons/IconInbox.vue Normal file
View File

@@ -0,0 +1,35 @@
<template>
<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"
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 16 12 14 15 10 15 8 12 2 12"></polyline>
<path
d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"
></path>
</svg> -->
</template>
<!-- <style lang="scss">
svg {
fill: var(--text-color);
stroke: none;
}
</style>
-->

8
src/icons/IconInfo.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<svg 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>
</template>

20
src/icons/IconKey.vue Normal file
View File

@@ -0,0 +1,20 @@
<template>
<svg viewBox="0 0 32 32" 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>
</template>

8
src/icons/IconMagnet.vue Normal file
View File

@@ -0,0 +1,8 @@
<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>
</template>

8
src/icons/IconMovie.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<svg 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>
</template>

View File

@@ -0,0 +1,38 @@
<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"
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"
>
<circle cx="12" cy="12" r="10"></circle>
<polygon points="10 8 16 12 10 16 10 8"></polygon>
</svg> -->
</template>
<!-- <style lang="scss" scoped>
svg {
fill: var(--text-color);
stroke: none;
}
</style>
-->

12
src/icons/IconPerson.vue Normal file
View File

@@ -0,0 +1,12 @@
<template>
<svg 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>
</template>

12
src/icons/IconPlay.vue Normal file
View File

@@ -0,0 +1,12 @@
<template>
<svg viewBox="0 0 32 32" 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>
</template>

29
src/icons/IconPopular.vue Normal file
View File

@@ -0,0 +1,29 @@
<template>
<svg 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"
/>
</svg>
</template>

14
src/icons/IconProfile.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<svg 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"
/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg 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>
</template>

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

@@ -0,0 +1,17 @@
<template>
<svg 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"
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>
</template>

View File

@@ -0,0 +1,17 @@
<template>
<svg 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"
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>
</template>

13
src/icons/IconSearch.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 32 32"
style="transition-duration: 0s;"
>
<path
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"
/>
</svg>
</template>

View File

@@ -0,0 +1,24 @@
<template>
<svg
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"
>
<line x1="4" y1="21" x2="4" y2="14"></line>
<line x1="4" y1="10" x2="4" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="3"></line>
<line x1="20" y1="21" x2="20" y2="16"></line>
<line x1="20" y1="12" x2="20" y2="3"></line>
<line x1="1" y1="14" x2="7" y2="14"></line>
<line x1="9" y1="8" x2="15" y2="8"></line>
<line x1="17" y1="16" x2="23" y2="16"></line>
</svg>
</template>

18
src/icons/IconShow.vue Normal file
View File

@@ -0,0 +1,18 @@
<template>
<svg 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>
<path
d="M4.667 26.667h17.333c0.367 0 0.667-0.3 0.667-0.667v-14.667c0-0.367-0.3-0.667-0.667-0.667h-17.333c-0.367 0-0.667 0.3-0.667 0.667v14.667c0 0.367 0.3 0.667 0.667 0.667zM5.333 12h16v13.333h-16v-13.333z"
></path>
<path
d="M26 16c1.104 0 2-0.896 2-2s-0.896-2-2-2-2 0.896-2 2 0.896 2 2 2zM26 13.333c0.367 0 0.667 0.3 0.667 0.667s-0.3 0.667-0.667 0.667-0.667-0.3-0.667-0.667 0.3-0.667 0.667-0.667z"
></path>
<path d="M24 24h4v1.333h-4v-1.333z"></path>
<path d="M24 21.333h4v1.333h-4v-1.333z"></path>
<path
d="M22.917 2.229l-0.688-1.146-6.854 4.112-5.508-4.129-0.8 1.067 5.867 4.4c0.117 0.088 0.258 0.133 0.4 0.133 0.117 0 0.238-0.033 0.342-0.096l7.242-4.342z"
></path>
</svg>
</template>

18
src/icons/IconShrink.vue Normal file
View File

@@ -0,0 +1,18 @@
<template>
<svg
id="icon-full-screen-exit"
viewBox="0 0 32 32"
width="100%"
height="100%"
>
<path
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
d="M26 7.725l-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
d="M11.333 17.333h-3.333v1.333h1.725l-4.667 4.667 0.942 0.942 4.667-4.667v1.725h1.333v-3.333c0-0.367-0.3-0.667-0.667-0.667z"
></path>
</svg>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
<template>
<svg id="icon-calendar4" viewBox="0 0 32 32" width="100%" height="100%">
<path fill="inherit" d="M0 1.333h6.667v2.667h-6.667v-2.667z"></path>
<path fill="inherit" d="M12 1.333h8v2.667h-8v-2.667z"></path>
<path fill="inherit" d="M25.333 1.333h6.667v2.667h-6.667v-2.667z"></path>
<path fill="inherit" d="M8 0h2.667v9.333h-2.667v-9.333z"></path>
<path fill="inherit" d="M21.333 0h2.667v9.333h-2.667v-9.333z"></path>
<path fill="inherit" d="M12 5.333h8v2.667h-8v-2.667z"></path>
<path
fill="inherit"
d="M29.333 5.333h-4v2.667h4v21.333h-20v-6c0-0.367-0.3-0.667-0.667-0.667h-6v-14.667h4v-2.667h-4c-1.471 0-2.667 1.196-2.667 2.667v14.667c0 5.146 4.188 9.333 9.333 9.333h20c1.471 0 2.667-1.196 2.667-2.667v-21.333c0-1.471-1.196-2.667-2.667-2.667zM2.8 24h5.2v5.2c-2.608-0.533-4.667-2.592-5.2-5.2z"
></path>
<path fill="inherit" d="M14.667 12h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M18.667 12h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M22.667 12h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M10.667 14.667h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M14.667 14.667h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M18.667 14.667h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M22.667 14.667h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M6.667 14.667h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M10.667 17.333h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M14.667 17.333h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M18.667 17.333h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M22.667 17.333h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M6.667 17.333h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M10.667 20h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M14.667 20h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M18.667 20h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M22.667 20h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M6.667 20h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M10.667 22.667h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M14.667 22.667h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M18.667 22.667h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M22.667 22.667h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M10.667 25.333h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M14.667 25.333h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M18.667 25.333h2.667v1.333h-2.667v-1.333z"></path>
<path fill="inherit" d="M22.667 25.333h2.667v1.333h-2.667v-1.333z"></path>
</svg>
</template>

70
src/icons/tmdb-logo.vue Normal file
View File

@@ -0,0 +1,70 @@
<template>
<svg
version="1.1"
width="100%"
height="100%"
viewBox="0 0 225 199"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style="outline-offset: -5px"
>
<title>TMDB Logo</title>
<path
d="M202.545 185.929C215.58 185.929 224.402 177.107 224.402 164.071V21.8571C224.402 8.82143 215.58 0 202.545 0H21.8571C8.82143 0 0 8.82143 0 21.8571V198.937L11.2143 185.937V21.8571C11.2241 15.9833 15.9833 11.2241 21.8571 11.2143H202.545C208.418 11.2241 213.178 15.9833 213.188 21.8571V164.071C213.178 169.945 208.418 174.704 202.545 174.714H38.4911L27.2768 185.929L27.2054 185.839"
fill="#01D277"
/>
<path
d="M42.1677 67.911H44.0133C44.9484 67.911 45.699 67.7137 46.265 67.3192C46.8556 66.9247 47.1509 66.2589 47.1509 65.3219C47.1509 64.3849 46.8556 63.7192 46.265 63.3247C45.699 62.9301 44.9484 62.7329 44.0133 62.7329H42.1677V67.911ZM44.0133 57.5548C45.3914 57.5548 46.6218 57.7644 47.7046 58.1836C48.8119 58.5781 49.747 59.1329 50.5099 59.8479C51.2727 60.5384 51.851 61.3644 52.2448 62.326C52.6631 63.263 52.8723 64.2616 52.8723 65.3219C52.8723 66.8014 52.5278 68.0466 51.8387 69.0575C51.1743 70.0685 50.2884 70.8575 49.181 71.4247L58.04 83.2603L58.1686 83.4452H51.9025L51.7649 83.2603L44.0133 73.089H42.1677V83.4452H41.9832H37V83.2603V57.7397L37.1846 57.5548H44.0133Z"
fill="#01D277"
/>
<path
d="M76.6677 57.5548H76.8523V62.7329H76.6677H64.3021V67.3562H73.3456H73.5302V72.5342H73.3456H64.3021V78.0857H77.0368H77.2214V83.4452H77.0368H59.1344V83.2603L59.0419 57.5548H59.3189H76.6677Z"
fill="#01D277"
/>
<path
d="M94.7735 72.3493L96.8775 74.1986C97.4435 73.1137 97.7264 71.8808 97.7264 70.5C97.7264 69.3411 97.5173 68.2685 97.0989 67.2822C96.7052 66.2959 96.1392 65.4452 95.401 64.7301C94.6873 63.9904 93.8507 63.411 92.8909 62.9918C91.9312 62.5726 90.8977 62.363 89.7903 62.363C88.6829 62.363 87.6494 62.5726 86.6897 62.9918C85.73 63.411 84.881 63.9904 84.1427 64.7301C83.4291 65.4452 82.8631 66.2959 82.4448 67.2822C82.051 68.2685 81.8542 69.3411 81.8542 70.5C81.8542 71.6589 82.051 72.7315 82.4448 73.7178C82.8631 74.7041 83.4291 75.5671 84.1427 76.3068C84.881 77.0219 85.73 77.589 86.6897 78.0082C87.6494 78.4274 88.6829 78.637 89.7903 78.637C90.9961 78.637 92.1281 78.3904 93.1862 77.8973L91.2668 76.2329V75.863L94.4043 72.3493H94.7735ZM103.079 79.9315L99.9412 83.4452H99.5721L97.505 81.6699C96.3976 82.4096 95.1918 82.989 93.8876 83.4082C92.6079 83.8027 91.2422 84 89.7903 84C87.8955 84 86.1114 83.6548 84.438 82.9644C82.7893 82.274 81.3497 81.3247 80.1193 80.1164C78.8889 78.8836 77.9169 77.4534 77.2032 75.826C76.4896 74.174 76.1328 72.3986 76.1328 70.5C76.1328 68.6014 76.4896 66.8384 77.2032 65.211C77.9169 63.5589 78.8889 62.1288 80.1193 60.9205C81.3497 59.6877 82.7893 58.726 84.438 58.0356C86.1114 57.3452 87.8955 57 89.7903 57C91.6851 57 93.4569 57.3452 95.1057 58.0356C96.779 58.726 98.2309 59.6877 99.4613 60.9205C100.692 62.1288 101.664 63.5589 102.377 65.211C103.091 66.8384 103.448 68.6014 103.448 70.5C103.448 71.9055 103.251 73.237 102.857 74.4945C102.464 75.7274 101.91 76.8863 101.196 77.9712L103.079 79.5616V79.9315Z"
fill="#01D277"
/>
<path
d="M110.657 57.5548H110.842V71.2397C110.842 72.4973 111.002 73.5945 111.322 74.5315C111.642 75.4438 112.085 76.2082 112.651 76.8247C113.217 77.4164 113.881 77.8726 114.644 78.1932C115.407 78.489 116.231 78.637 117.117 78.637C118.003 78.637 118.827 78.489 119.59 78.1932C120.353 77.8726 121.017 77.4164 121.583 76.8247C122.149 76.2082 122.592 75.4438 122.912 74.5315C123.232 73.5945 123.392 72.4973 123.392 71.2397V57.5548H123.577H128.375H128.56V71.9795C128.56 73.8781 128.252 75.5795 127.637 77.0836C127.046 78.563 126.234 79.8205 125.201 80.8562C124.167 81.8671 122.949 82.6438 121.546 83.1863C120.168 83.7288 118.692 84 117.117 84C115.542 84 114.053 83.7288 112.651 83.1863C111.272 82.6438 110.067 81.8671 109.033 80.8562C108 79.8205 107.175 78.563 106.56 77.0836C105.969 75.5795 105.674 73.8781 105.674 71.9795V57.5548H105.859H110.657Z"
fill="#01D277"
/>
<path
d="M149.055 57.5548H149.239V62.7329H149.055H136.689V67.3562H145.733H145.917V72.5342H145.733H136.689V78.0236H149.424H149.608V83.4452H149.424H131.521V83.2603V57.5548H131.706H149.055Z"
fill="#01D277"
/>
<path
d="M161.497 64.0274C161.079 63.4603 160.611 63.0411 160.094 62.7699C159.602 62.4986 159.122 62.363 158.655 62.363C158.015 62.363 157.547 62.5356 157.252 62.8808C156.957 63.2014 156.809 63.6452 156.809 64.2123C156.809 64.7055 157.08 65.137 157.621 65.5068C158.163 65.8767 158.827 66.2466 159.614 66.6164C160.427 66.9863 161.3 67.4055 162.235 67.874C163.195 68.3178 164.069 68.8849 164.856 69.5753C165.668 70.2658 166.345 71.1041 166.886 72.0904C167.428 73.0767 167.698 74.2726 167.698 75.6781C167.698 76.7877 167.477 77.8479 167.034 78.8589C166.615 79.8699 166.013 80.7575 165.225 81.5219C164.462 82.2616 163.552 82.8658 162.494 83.3342C161.435 83.7781 160.279 84 159.024 84C158.064 84 157.129 83.8767 156.219 83.6301C155.308 83.3836 154.471 83.0137 153.709 82.5205C152.946 82.0274 152.269 81.4356 151.678 80.7452C151.112 80.0301 150.669 79.2041 150.349 78.2671L154.594 75.4192H154.964C155.431 76.6521 156.034 77.5027 156.772 77.9712C157.535 78.4151 158.286 78.637 159.024 78.637C159.959 78.637 160.71 78.3781 161.276 77.8603C161.866 77.3425 162.161 76.6151 162.161 75.6781C162.161 74.9384 161.891 74.3096 161.349 73.7918C160.808 73.274 160.131 72.7932 159.319 72.3493C158.532 71.8808 157.658 71.4247 156.698 70.9808C155.763 70.537 154.89 70.0192 154.078 69.4274C153.29 68.811 152.626 68.0959 152.084 67.2822C151.543 66.4438 151.272 65.4205 151.272 64.2123C151.272 63.1521 151.457 62.1781 151.826 61.2904C152.22 60.4027 152.749 59.6507 153.413 59.0342C154.078 58.3932 154.853 57.9 155.739 57.5548C156.649 57.1849 157.621 57 158.655 57C160.23 57 161.571 57.3329 162.678 57.9986C163.786 58.6644 164.844 59.6877 165.853 61.0685L161.866 64.0274H161.497Z"
fill="#01D277"
/>
<path
d="M187.815 57.5548H188V62.5479V62.7329H180.248V83.2603V83.4452H175.081V83.2603V62.7329H167.329V62.5479V57.5548H167.514H187.815Z"
fill="#01D277"
/>
<path
d="M61.9051 108.406L49.9478 94.7412H48V126.338H54.149V108.97L61.9051 117.327L69.6613 108.97L69.6263 126.338H75.7666V94.7412H73.8625L61.9051 108.406Z"
fill="#01D277"
/>
<path
d="M96.831 94.7412C76.4049 94.7412 76.4049 126.338 96.831 126.338C117.257 126.338 117.257 94.7412 96.831 94.7412ZM96.831 119.995C84.9585 119.995 84.9585 101.048 96.831 101.048C108.703 101.048 108.703 119.995 96.831 119.995Z"
fill="#01D277"
/>
<path
d="M158.109 95.6987H152.364V126.338H158.109V95.6987Z"
fill="#01D277"
/>
<path
d="M169.222 120.21V114.082H179.357V107.954H169.222V101.827H180.551V95.6987H162.896V126.338H181.088V120.21H169.222Z"
fill="#01D277"
/>
<path
d="M132.262 112.402L124.095 95.6987H116.938L131.565 127.295H132.959L147.577 95.6987H140.42L132.262 112.402Z"
fill="#01D277"
/>
<path
d="M42.425 121.5H42.6V126.4H42.425H37.7V126.225V121.5H37.875H42.425Z"
fill="#01D277"
/>
</svg>
</template>

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