52 Commits

Author SHA1 Message Date
3c8463f06c disabled yarn building packages 2020-01-20 23:40:33 +01:00
a34313ea6f run deploy script on api host 2020-01-20 20:55:02 +01:00
0ac90f1b17 Update .drone.yml 2020-01-20 20:18:49 +01:00
d0c32c267a Update .drone.yml 2020-01-20 20:18:23 +01:00
9586d71ca0 Update .drone.yml 2020-01-20 19:06:07 +01:00
64aa607924 test branch added to build triggers 2020-01-20 19:05:17 +01:00
32669e5bef Update .drone.yml 2020-01-20 19:00:36 +01:00
6edad3991f Create .drone.yml 2020-01-20 18:59:58 +01:00
50acf0bedc Merge pull request #46 from KevinMidboe/fix/admin-from-jwt
Fix/admin from jwt
2020-01-10 23:32:43 +01:00
d4369ec7a4 JwtToken decoded to set data from jwt contents.
JwtDecode used to read data from the jwt token and set admin and
username. Resolves issue #45.
2020-01-10 23:29:34 +01:00
c16543099e JwtDecode function for reading content of jwt token. 2020-01-10 23:28:45 +01:00
f2a65d755c Show info also appends check_existance to url. 2019-12-27 23:39:35 +01:00
532993e9dd Merge pull request #41 from KevinMidboe/refactor
General refactoring and small feature release
2019-12-27 22:04:36 +01:00
d19d72ce0c Merge pull request #40 from KevinMidboe/feature/user-graphs
Authenticate plex account in settings gives access to activity graph for your plex user
2019-12-27 22:01:49 +01:00
d1820a08cf Same css for .light & .dark as for color-scheme. 2019-12-27 21:51:31 +01:00
bc73665b12 Elem text wrapped <li> and active icon ref fixed.
Supplementary and content text are now wrapped in a <li> item. This with
better styling selectors formats the icon correctly alongside the text.
Fixed active icon ref function that had an incorrect if statement so the
activeIconRef would never be returned.
2019-12-26 12:46:57 +01:00
9edb19569a Change to have height to min-height: 75px 2019-12-26 12:21:43 +01:00
7802a89d15 New toggle release filter and fixed expand torrent
Use the toggleButton for filtering release types in torrent response.
There is a click to expand the full name of the torrent. This is mostly
for mobile where the name is hidden. Fixed an issue where the expanded
list element would not get the correct styling and break the table in
half. Now we also set an data attribute for the expanded element. This
allows our scoped styling to reach the expanded element.
Also increased padding on expanded content.
2019-12-26 12:04:17 +01:00
915260f41b Admin var checks localstorage for admin == "true". 2019-12-26 12:01:26 +01:00
0d57e9a03b Tmdb search w/ adult and media type filters.
Adult is set to disable filtering adult material for search results.
Media type checks if movie, show or person and appends type to url path
for searches by type only. E.g. mediaType = 'show' ->
api/v1/search/show?query=Friends.
2019-12-26 01:59:01 +01:00
582207d453 Error msg on empty response & added search params
- On empty search responses Search page show a error message that there
where no respones.
- For page loads directly to the search page new url query parameters
are checked: adult and media_type. These are then used to fetch updated
tmdb search parameters. Adult = true disables filtering for adult
material and media_type decides if the search is multi, movie, show or
person. (Frontend for filtering media_type is not added yet.)
2019-12-26 01:37:29 +01:00
b1b08bfa04 Movielist item shows the title and now name for cases where the result item is a person. 2019-12-26 01:35:56 +01:00
14e883672d Higher z-index & checks for browser compatibility.
- Increased the z-index of the darkmode toggle emoji icon.
- supported function for checking the browser for prefered color scheme.
This is mainly to set the current mode to dark if the color scheme is
currently dark.
2019-12-26 01:32:20 +01:00
7a405140db Loading var for loader start and more header info.
New loading var for holding the state of the request. This makes it
easier to show the loading and an error if the result is empty but the
request is finished.
More header info! The header now displays list elements of info in a
column on the right side of header. Used here for result and page
current and total count.
2019-12-26 01:26:46 +01:00
35497f5bd2 General mobile & desktop queries. -only classes for hiding the the
opposite of desktop or mobile.
2019-12-26 01:18:29 +01:00
91b19785d6 Darker colors for background-color for preferred color schema dark 2019-12-26 01:17:18 +01:00
a301d21cc2 Merge branch 'feature/user-graphs' into refactor 2019-12-26 01:14:34 +01:00
a2a4b9a553 getPerson endpoint and is called properly when movie.vue opens with type 'person'. 2019-12-26 01:08:19 +01:00
45f45559fd Day number input has defined background and text color. 2019-12-26 00:35:46 +01:00
458256132a Updated color for .light --background-ui 2019-12-26 00:33:05 +01:00
0f2c166e1c Added $background-ui to .dark and .light classes for manually toggling color preference 2019-12-26 00:31:30 +01:00
1c7a688cb8 Moved fetch call for getting charts to api.js 2019-12-26 00:28:33 +01:00
6269f178e9 Store added to api 2019-12-26 00:20:40 +01:00
3e7527ee19 defined variables for green-70 (rgba(1, 210, 119, .73)) and background-ui (#edeef0) 2019-12-26 00:18:14 +01:00
2236316863 api functions for linking, unlinking-plex account, get settings and update settings. 2019-12-26 00:15:45 +01:00
cc2fded193 Removed unnessesary filename declaratiom 2019-12-26 00:10:03 +01:00
f32e0a8ab0 Store user module for users settings and username. 2019-12-26 00:09:02 +01:00
ec6e6d2ba0 Class declarations for simple flex selectors. 2019-12-26 00:08:26 +01:00
ca85635b03 Settings with new feature to autnhenticate plex account with your season account. Also moved settings to computed value of new user store module. 2019-12-26 00:06:56 +01:00
32257dc64e Info can now also be Array and will display the list elements in a column. Also made hader sticky and decreased some margin and increased the font. 2019-12-24 13:23:22 +01:00
6bba319735 Formatting 2019-12-24 13:14:10 +01:00
dcce972fdc New togglebutton component for selection data types for the graphs in activitypage. 2019-12-24 13:14:00 +01:00
32e25fb983 Accounts with linked plex accounts can view their watch history per day by duration on number of plays. Have two graphs and adding more requires a new canvas element and new list element in this.charts. 2019-12-24 13:08:57 +01:00
e7882869e6 Created checkStatusAndReturnJson middleware for checking for responses status is ok(200-299) or not and if it does returns a json parsed object. 2019-11-25 23:28:23 +01:00
d0a251f69a Moved register and login requests to api.js. 2019-11-25 23:25:08 +01:00
9bc7f29162 Decreased the padding around movie list items on large screens to let the grow a bit larger. 2019-11-25 23:12:36 +01:00
3ff963f007 Implemented download activity overlay on movielistitems if progress movie object key exists. 2019-11-25 23:11:59 +01:00
bcfce66ec0 Matched is set to false if exists_in_plex is undefined (not a part of
the response body).
nesteDataToString is simplified in the way it parses its input data.
getMovie uses optional second parameter check_existance to check if the
file exists in plex.
2019-11-25 23:03:22 +01:00
33e3ee3489 Authenticating with plex now happens to seasonedShows backend and not through plex.tv. 2019-11-25 22:57:05 +01:00
e3502a7690 Searching tmdb should also include authorization token for search history. 2019-11-25 22:56:01 +01:00
8d09ba4d07 get movie now has optional url parameters to also include existance and release dates in response 2019-11-25 22:55:34 +01:00
ba670d06aa Added charjs and fetch user activity to graph from new user/activity endpoint. This fetches tautulli stats based on the plex user_id linked with the seasoned account. 2019-11-05 01:08:38 +01:00
29 changed files with 1180 additions and 532 deletions

44
.drone.yml Normal file
View File

@@ -0,0 +1,44 @@
---
kind: pipeline
type: docker
name: default
platform:
os: linux
arch: amd64
steps:
- name: frontend_install
image: node:13.6.0
commands:
- node -v
- yarn --version
- name: deploy
image: appleboy/drone-ssh
pull: true
secrets:
- ssh_key
when:
event:
- push
branch:
- master
- drone-test
status: success
settings:
host: 10.0.0.114
username: root
key:
from_secret: ssh_key
command_timeout: 600s
script:
- /home/kevin/deploy/seasoned.sh
trigger:
branch:
- master
- drone-test
event:
include:
- pull_request
- push

View File

@@ -13,6 +13,7 @@
"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",

View File

@@ -2,6 +2,7 @@ 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
@@ -10,6 +11,13 @@ 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 - - -
/**
@@ -18,12 +26,18 @@ const ELASTIC_INDEX = config.ELASTIC_INDEX
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getMovie = (id, credits=false) => {
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())
@@ -36,9 +50,12 @@ const getMovie = (id, credits=false) => {
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getShow = (id, credits=false) => {
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)
}
@@ -48,6 +65,24 @@ const getShow = (id, credits=false) => {
.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
@@ -96,12 +131,19 @@ const getUserRequests = (page=1) => {
* @param {number} [page=1]
* @returns {object} Tmdb response
*/
const searchTmdb = (query, page=1) => {
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)
return fetch(url.href)
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 })
}
@@ -210,32 +252,138 @@ const getRequestStatus = (id, type, authorization_token=undefined) => {
.catch(err => Promise.reject(err))
}
// - - - Authenticate with plex - - -
// - - - Seasoned user endpoints - - -
const plexAuthenticate = (username, password) => {
const url = new URL('https://plex.tv/api/v2/users/signin')
const headers = {
'Content-Type': 'application/json',
'X-Plex-Platform': 'Linux',
'X-Plex-Version': 'v2.0.24',
'X-Plex-Platform-Version': '4.13.0-36-generic',
'X-Plex-Device-Name': 'Tautulli',
'X-Plex-Client-Identifier': '123'
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 })
}
let formData = new FormData()
formData.set('login', username)
formData.set('password', password)
formData.set('rememberMe', false)
return axios({
method: 'POST',
url: url.href,
headers: headers,
data: formData
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
})
.catch(error => { console.error(`api error authentication plex: ${username}`); throw error })
}
const login = (username, password) => {
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 => resp.json())
.catch(error => {
console.error('Unexpected error occured before receiving response. Error:', error)
// TODO log to sentry the issue here
throw error
})
}
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 })
}
@@ -302,6 +450,7 @@ const elasticSearchMoviesAndShows = (query) => {
export {
getMovie,
getShow,
getPerson,
getTmdbMovieListByName,
searchTmdb,
getUserRequests,
@@ -310,7 +459,13 @@ export {
addMagnet,
request,
getRequestStatus,
plexAuthenticate,
linkPlexAccount,
unlinkPlexAccount,
register,
login,
getSettings,
updateSettings,
fetchChart,
getEmoji,
elasticSearchMoviesAndShows
}

View File

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

View File

@@ -2,8 +2,11 @@
<header :class="{ 'sticky': sticky }">
<h2>{{ title }}</h2>
<span v-if="info" class="result-count">{{ info }}</span>
<router-link v-else-if="link" :to="link" class='view-more'>
<div v-if="info instanceof Array" class="flex flex-direction-column">
<span v-for="item in info" class="info">{{ item }}</span>
</div>
<span v-else class="info">{{ info }}</span>
<router-link v-if="link" :to="link" class='view-more' :aria-label="`View all ${title}`">
View All
</router-link>
</header>
@@ -19,10 +22,10 @@ export default {
sticky: {
type: Boolean,
required: false,
default: false
default: true
},
info: {
type: String,
type: [String, Array],
required: false
},
link: {
@@ -37,12 +40,16 @@ export default {
<style lang="scss" scoped>
@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;
padding: 1.8rem 12px;
align-items: center;
padding-left: 0.75rem;
padding-right: 0.75rem;
&.sticky {
background-color: $background-color;
@@ -51,22 +58,19 @@ header {
position: -webkit-sticky;
top: $header-size;
z-index: 4;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
h2 {
font-size: 18px;
font-size: 1.4rem;
font-weight: 300;
text-transform: capitalize;
line-height: 18px;
line-height: 1.4rem;
margin: 0;
color: $text-color;
}
.view-more {
font-size: 13px;
font-size: 0.9rem;
font-weight: 300;
letter-spacing: .5px;
color: $text-color-70;
@@ -82,12 +86,13 @@ header {
}
}
.result-count {
.info {
font-size: 13px;
font-weight: 300;
letter-spacing: .5px;
color: $text-color;
text-decoration: none;
text-align: right;
}
@include tablet-min {

View File

@@ -1,7 +1,7 @@
<template>
<div>
<list-header :title="listTitle" :info="resultCount" :sticky="true" />
<list-header :title="listTitle" :info="info" :sticky="true" />
<results-list :results="results" v-if="results" />
@@ -30,7 +30,8 @@ export default {
results: [],
page: 1,
totalPages: 0,
totalResults: 0
totalResults: 0,
loading: true
}
},
computed: {
@@ -42,18 +43,24 @@ export default {
console.log('routelistname', routeListName)
return routeListName.includes('_') ? routeListName.split('_').join(' ') : routeListName
},
resultCount() {
info() {
if (this.results.length === 0)
return ''
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}`)
@@ -82,6 +89,8 @@ export default {
// TODO handle if list is not found
console.log('404 this is not a tmdb list')
}
this.loading = false
}
},
created() {

View File

@@ -32,28 +32,23 @@
<!-- SIDEBAR ACTIONS -->
<div class="movie__actions" v-if="movie">
<sidebar-list-element :iconRef="'#iconNot_exsits'" :active="requested"
:iconRefActive="'#iconExists'" :textActive="'Already in plex 🎉'" :class="requested ? 'rotate-180' : null">
<sidebar-list-element :iconRef="'#iconNot_exsits'" :active="matched"
:iconRefActive="'#iconExists'" :textActive="'Already in plex 🎉'">
Not yet in plex
</sidebar-list-element>
<sidebar-list-element @click="sendRequest" :iconRef="'#iconSent'"
:active="requested" :textActive="'Requested to be downloaded'">
Request to be downloaded?
</sidebar-list-element>
<sidebar-list-element v-if="admin" @click="showTorrents=!showTorrents"
:iconRef="'#icon_torrents'" :active="showTorrents"
:supplementaryText="numberOfTorrentResults">
Search for torrents
</sidebar-list-element>
<sidebar-list-element @click="showIssueForm = !showIssueForm"
:iconRef="null"
:active="showIssueForm">
&nbsp; &nbsp;Report an issue!
</sidebar-list-element>
<sidebar-list-element @click="openTmdb" :iconRef="'#icon_info'">
See more info
</sidebar-list-element>
@@ -69,16 +64,14 @@
<!-- MOVIE INFO -->
<div class="movie__info">
<div class="movie__description" v-if="movie"> {{ movie.overview }}</div>
<!-- Loading placeholder -->
<div v-if="!movie" class="movie__description">
<div v-else class="movie__description">
<loading-placeholder :count="12" />
</div>
<div class="movie__details" v-if="movie && !showIssueForm">
<div class="movie__description">
{{ movie.overview }}
</div>
<div class="movie__details" v-if="movie">
<div v-if="movie.year" class="movie__details-block">
<h2 class="movie__details-title">Release Date</h2>
<div class="movie__details-text">{{ movie.year }}</div>
@@ -100,17 +93,6 @@
</div>
</div>
<div v-if="showIssueForm" class="issueForm">
<h2 class="movie__details-title">Report an issue</h2>
<RadioButtons class="issueOptions"
:options="issueOptions"
:value.sync="selectedIssue" />
<TextArea title="Additional information" :rows="3"
placeholder="Placeholder text" />
<SeasonedButton @click="reportIssue">Report issue</SeasonedButton>
</div>
</div>
<!-- TODO: change this classname, this is general -->
@@ -140,23 +122,12 @@ import Person from './Person'
import SidebarListElement from './ui/sidebarListElem'
import store from '@/store'
import LoadingPlaceholder from './ui/LoadingPlaceholder'
import RadioButtons from './ui/RadioButtons'
import TextArea from './ui/TextArea'
import SeasonedButton from './ui/SeasonedButton'
import { getMovie, getShow, request, getRequestStatus } from '@/api'
import { getMovie, getPerson, getShow, request, getRequestStatus } from '@/api'
export default {
props: ['id', 'type'],
components: {
TorrentList,
Person,
LoadingPlaceholder,
SidebarListElement,
RadioButtons,
TextArea,
SeasonedButton
},
components: { TorrentList, Person, LoadingPlaceholder, SidebarListElement },
directives: { img: img }, // TODO decide to remove or use
data(){
return{
@@ -169,11 +140,9 @@ export default {
matched: false,
userLoggedIn: storage.sessionId ? true : false,
requested: false,
admin: localStorage.getItem('admin'),
admin: localStorage.getItem('admin') == "true" ? true : false,
showTorrents: false,
compact: false,
showIssueForm: false,
selectedIssue: null
compact: false
}
},
methods: {
@@ -182,7 +151,7 @@ export default {
this.title = movie.title
this.poster = movie.poster
this.backdrop = movie.backdrop
this.matched = movie.existsInPlex
this.matched = movie.exists_in_plex || false
this.checkIfRequested(movie)
.then(status => this.requested = status)
@@ -192,9 +161,7 @@ export default {
return await getRequestStatus(movie.id, movie.type)
},
nestedDataToString(data) {
let nestedArray = []
data.forEach(item => nestedArray.push(item));
return nestedArray.join(', ');
return data.join(', ')
},
sendRequest(){
request(this.id, this.type, storage.token)
@@ -208,15 +175,6 @@ export default {
const tmdbType = this.type === 'show' ? 'tv' : this.type
window.location.href = 'https://www.themoviedb.org/' + tmdbType + '/' + this.id
},
reportIssue() {
if (this.showIssueForm) {
this.$notifications.success({
title: 'Issue successfully submitted',
description: 'Reported issue: Missing subtitles',
timeout: 300000
})
}
}
},
watch: {
id: function(val){
@@ -231,35 +189,6 @@ export default {
numberOfTorrentResults: () => {
let numTorrents = store.getters['torrentModule/resultCount']
return numTorrents !== null ? numTorrents + ' results' : null
},
issueOptions: function() {
return [{
value: 'playback',
text: 'Unable to play'
}, {
value: 'missing-episode',
text: 'Missing Episode',
subElements: this.seasonOptions
}, {
value: 'missing-subtitle',
text: 'Missing subtitles'
}]
},
seasonOptions: function() {
if (this.movie.type !== 'show') {
return []
}
const options = []
const length = this.movie.seasons;
for (var i = 0; i < length; i++) {
options.push({
value: i+1,
text: `Season ${i+1}`
})
}
return options;
}
},
beforeDestroy() {
@@ -269,13 +198,19 @@ export default {
this.prevDocumentTitle = store.getters['documentTitle/title']
if (this.type === 'movie') {
getMovie(this.id)
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)
getShow(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: '404' });
@@ -293,9 +228,6 @@ export default {
@import "./src/scss/media-queries";
.movie {
background-color: $background-color;
color: $text-color;
&__wrap {
display: flex;
&--header {
@@ -309,6 +241,9 @@ export default {
@include tablet-min{
flex-direction: row;
}
background-color: $background-color;
color: $text-color;
}
}
&__header {
@@ -429,19 +364,15 @@ export default {
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
flex: 0 0 100%;
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
}
&__details {
display: flex;
width: 100%;
flex-direction: row;
flex-wrap: wrap;
&-block {
float: left;
}
&-block:not(:last-child) {
margin-bottom: 20px;
margin-right: 20px;
@@ -490,24 +421,4 @@ export default {
}
}
}
.issueForm {
// padding: 40px;
.issueOptions {
margin-top: 1rem;
}
.seasonOptions {
margin-top: 2rem;
h2 {
margin-bottom: 1rem;
}
> :not(h2) {
margin-left: 1rem;
}
}
}
</style>

View File

@@ -6,9 +6,14 @@
<figure class="movies-item__poster">
<img v-if="!noImage" class="movies-item__img" src="~assets/placeholder.png" v-img="poster()" alt="">
<img v-if="noImage" class="movies-item__img is-loaded" src="~assets/no-image.png" alt="">
<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="movies-item__content">
<p class="movies-item__title">{{ movie.title }}</p>
<p class="movies-item__title">{{ movie.title || movie.name }}</p>
<p class="movies-item__title">{{ movie.year }}</p>
</div>
</a>
@@ -67,7 +72,7 @@ export default {
}
@include desktop-lg-min{
padding: 20px;
padding: 15px;
width: 12.5%;
}
@@ -115,3 +120,46 @@ export default {
}
}
</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

@@ -2,7 +2,7 @@
<section class="profile">
<div class="profile__content" v-if="userLoggedIn">
<header class="profile__header">
<h2 class="profile__title">{{ emoji }} Welcome {{ userName }}</h2>
<h2 class="profile__title">{{ emoji }} Welcome {{ username }}</h2>
<div class="button--group">
<seasoned-button @click="showSettings = !showSettings">{{ showSettings ? 'hide settings' : 'show settings' }}</seasoned-button>
@@ -13,7 +13,7 @@
<settings v-if="showSettings"></settings>
<list-header title="User requests" :info="resultCount"/>
<list-header title="User requests" :info="resultCount" />
<results-list v-if="results" :results="results" />
</div>
@@ -43,7 +43,6 @@ export default {
data(){
return{
userLoggedIn: '',
userName: '',
emoji: '',
results: undefined,
totalResults: undefined,
@@ -58,25 +57,10 @@ export default {
const loadedResults = this.results.length
const totalResults = this.totalResults < 10000 ? this.totalResults : '∞'
return `${loadedResults} of ${totalResults} results`
}
},
username: () => store.getters['userModule/username']
},
methods: {
createSession(token){
axios.get(`https://api.themoviedb.org/3/authentication/session/new?api_key=${storage.apiKey}&request_token=${token}`)
.then(function(resp){
let data = resp.data;
if(data.success){
let id = data.session_id;
localStorage.setItem('session_id', id);
eventHub.$emit('setUserStatus');
this.userLoggedIn = true;
this.getUserInfo();
}
}.bind(this));
},
getUserInfo(){
this.userName = localStorage.getItem('username');
},
toggleSettings() {
this.showSettings = this.showSettings ? false : true;
},
@@ -91,7 +75,6 @@ export default {
this.userLoggedIn = false;
} else {
this.userLoggedIn = true;
this.getUserInfo();
getUserRequests()
.then(results => {

View File

@@ -18,7 +18,7 @@
</template>
<script>
import axios from 'axios'
import { register } from '@/api'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedInput from '@/components/ui/SeasonedInput'
import SeasonedMessages from '@/components/ui/SeasonedMessages'
@@ -40,23 +40,20 @@ export default {
let verifyCredentials = this.checkCredentials(username, password, passwordRepeat);
if (verifyCredentials.verified) {
axios.post(`https://api.kevinmidboe.com/api/v1/user`, {
username: username,
password: password
})
.then(resp => {
let data = resp.data;
if (data.success){
localStorage.setItem('token', data.token);
localStorage.setItem('username', username);
localStorage.setItem('admin', data.admin)
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
register(username, password)
.then(data => {
if (data.success){
localStorage.setItem('token', data.token);
localStorage.setItem('username', username);
localStorage.setItem('admin', data.admin)
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
})
.catch(error => {
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.response.data.error })
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message })
});
}
else {

View File

@@ -8,10 +8,26 @@
<seasoned-button @click="loadMore">load more</seasoned-button>
</div>
<loader v-if="!results.length" />
<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'
@@ -37,6 +53,8 @@ export default {
query: String,
title: String,
page: Number,
adult: undefined,
mediaType: null,
totalPages: 0,
results: [],
totalResults: []
@@ -50,8 +68,8 @@ export default {
}
},
methods: {
search(query=this.query, page=this.page) {
searchTmdb(query, page)
search(query=this.query, page=this.page, adult=this.adult, mediaType=this.mediaType) {
searchTmdb(query, page, adult, mediaType)
.then(this.parseResponse)
},
parseResponse(data) {
@@ -74,14 +92,16 @@ export default {
}
},
created() {
const { query, page } = this.$route.query
const { query, page, adult, media_type } = this.$route.query
if (!query) {
// abort
console.error('abort, no query')
}
this.query = decodeURIComponent(query)
this.page = page ? page : 1
this.page = page || 1
this.adult = adult || this.adult
this.mediaType = media_type || this.mediaType
this.title = `Search results: ${this.query}`
this.search()

View File

@@ -3,17 +3,23 @@
<div class="profile__content" v-if="userLoggedIn">
<section class='settings'>
<h3 class='settings__header'>Plex account</h3>
<span class="settings__info">Sign in to your plex account to get information about recently added movies and to see your watch history</span>
<form class="form">
<seasoned-input placeholder="plex username" icon="Email" :value.sync="plexUsername"/>
<seasoned-input placeholder="plex password" icon="Keyhole" type="password"
:value.sync="plexPassword" @submit="authenticatePlex" />
<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>
<seasoned-button @click="authenticatePlex">link plex account</seasoned-button>
<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-messages :messages.sync="messages" />
</form>
<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'>
@@ -44,12 +50,13 @@
</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 { plexAuthenticate } from '@/api'
import { getSettings, updateSettings, linkPlexAccount, unlinkPlexAccount } from '@/api'
export default {
components: { SeasonedInput, SeasonedButton, SeasonedMessages },
@@ -60,7 +67,21 @@ export default {
plexUsername: null,
plexPassword: null,
newPassword: null,
newPasswordRepeat: 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: {
@@ -70,26 +91,37 @@ export default {
changePassword() {
return
},
authenticatePlex() {
async authenticatePlex() {
let username = this.plexUsername
let password = this.plexPassword
plexAuthenticate(username, password)
.then(resp => {
const data = resp.data
this.messages.push({ type: 'success', title: 'Authenticated with plex', message: 'Successfully linked plex account with seasoned request' })
const response = await linkPlexAccount(username, password)
console.log('response from plex:', data.username)
this.messages.push({
type: response.success ? 'success' : 'error',
title: response.success ? 'Authenticated with plex' : 'Something went wrong',
message: response.message
})
.catch(error => {
console.error(error);
this.messages.push({ type: 'error', title: 'Something went wrong', message: error.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(){
if (localStorage.getItem('token')){
const token = localStorage.getItem('token') || false;
if (token){
this.userLoggedIn = true
}
}
@@ -151,7 +183,7 @@ a {
display: block;
height: 1px;
border: 0;
border-bottom: 1px solid rgba(8, 28, 36, 0.05);
border-bottom: 1px solid $text-color-50;
margin-top: 30px;
margin-bottom: 70px;
margin-left: 20px;

View File

@@ -2,7 +2,10 @@
<section>
<h1>Sign in</h1>
<seasoned-input placeholder="username" icon="Email" type="username" :value.sync="username" />
<seasoned-input placeholder="username"
icon="Email"
type="email"
:value.sync="username" />
<seasoned-input placeholder="password" icon="Keyhole" type="password" :value.sync="password" @enter="signin"/>
<seasoned-button @click="signin">sign in</seasoned-button>
@@ -16,11 +19,12 @@
<script>
import axios from 'axios'
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 },
@@ -39,29 +43,26 @@ export default {
let username = this.username;
let password = this.password;
axios.post(`https://api.kevinmidboe.com/api/v1/user/login`, {
username: username,
password: password
})
.then(resp => {
let data = resp.data;
if (data.success){
localStorage.setItem('token', data.token);
localStorage.setItem('username', username);
localStorage.setItem('admin', data.admin);
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
})
.catch(error => {
if (error.message.endsWith('401')) {
this.messages.push({ type: 'warning', title: 'Access denied', message: 'Incorrect username or password' })
}
else {
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message })
}
});
login(username, password)
.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: 'warning', title: 'Access denied', message: 'Incorrect username or password' })
}
else {
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message })
}
});
}
},
created(){

View File

@@ -20,9 +20,11 @@
<div v-if="listLoaded">
<div v-if="torrents.length > 0">
<ul class="filter">
<!-- <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>
</ul> -->
<toggle-button :options="release_types" :selected.sync="selectedRelaseType" class="toggle"></toggle-button>
<table>
@@ -97,9 +99,10 @@ import { searchTorrents, addMagnet } from '@/api'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedInput from '@/components/ui/SeasonedInput'
import ToggleButton from '@/components/ui/ToggleButton'
export default {
components: { SeasonedButton, SeasonedInput },
components: { SeasonedButton, SeasonedInput, ToggleButton },
props: {
query: {
type: String,
@@ -110,7 +113,7 @@ export default {
require: true
},
tmdb_type: String,
admin: String,
admin: Boolean,
show: Boolean
},
data() {
@@ -133,6 +136,11 @@ export default {
}
store.dispatch('torrentModule/reset')
},
watch: {
selectedRelaseType: function(newValue) {
this.applyFilter(newValue)
}
},
methods: {
selectedSortableClass(headerName) {
return headerName === this.prevCol ? 'active' : ''
@@ -147,27 +155,31 @@ export default {
expand(event, name) {
const existingExpandedElement = document.getElementsByClassName('expanded')[0]
const clickedElement = event.target.parentNode;
const scopedStyleDataVariable = Object.keys(clickedElement.dataset)[0]
if (existingExpandedElement) {
console.log('exists')
const expandedSibling = event.target.parentNode.nextSibling.className === 'expanded'
existingExpandedElement.remove()
const table = document.getElementsByTagName('table')[0]
table.style.display = 'block'
if (expandedSibling) {
console.log('sibling is here')
return
}
}
console.log('expand event', event)
const nameRow = document.createElement('tr')
const nameCol = document.createElement('td')
nameRow.className = 'expanded'
nameRow.dataset[scopedStyleDataVariable] = "";
nameCol.innerText = name
nameCol.dataset[scopedStyleDataVariable] = "";
nameRow.appendChild(nameCol)
event.target.parentNode.insertAdjacentElement('afterend', nameRow)
clickedElement.insertAdjacentElement('afterend', nameRow)
},
sendTorrent(magnet, name, event){
this.$notifications.info({
@@ -177,7 +189,6 @@ export default {
})
event.target.parentNode.classList.add('active')
addMagnet(magnet, name, this.tmdb_id)
.catch((resp) => { console.log('error:', resp.data) })
.then((resp) => {
@@ -193,7 +204,6 @@ export default {
if (this.prevCol === col && sameDirection === false) {
this.direction = !this.direction
}
console.log('col and more', col, sameDirection)
switch (col) {
case 'name':
@@ -279,14 +289,13 @@ export default {
@import "./src/scss/variables";
.expanded {
display: flex;
margin: 0 1rem;
padding: 0.25rem 1rem;
max-width: 100%;
border-left: 1px solid $text-color;
border-right: 1px solid $text-color;
border-bottom: 1px solid $text-color;
td {
// border-left: 1px solid $c-dark;
word-break: break-all;
padding: 0.5rem 0.15rem;
width: 100%;
@@ -298,8 +307,14 @@ export default {
@import "./src/scss/media-queries";
@import "./src/scss/elements";
.toggle {
max-width: unset !important;
margin: 1rem 0;
}
.container {
background-color: $background-color;
padding: 0 1rem;
}
.torrentHeader {
@@ -348,7 +363,6 @@ table {
.table__content, .table__header {
display: flex;
padding: 0;
margin: 0 1rem;
border-left: 1px solid $text-color;
border-right: 1px solid $text-color;
border-bottom: 1px solid $text-color;

View File

@@ -1,133 +0,0 @@
<template>
<div>
<label v-for="option in options" class="radio" @click="selected = option.value">
<input type="radio" v-model="selected" :value="option.value" />
<label>{{ option.text }}</label>
<div class="sub-radios" v-if="option.subElements && selected === option.value">
<label class="radio" v-for="elem in option.subElements">
<input type="radio" v-model="selectedSubItem" :value="option.value + '-' + elem.value" />
<label>{{ elem.text }}</label>
</label>
</div>
</label>
</div>
</template>
<script>
export default {
props: {
options: {
type: Array,
required: true
},
value: {
required: false,
default: undefined
}
},
data() {
return {
selected: this.value || this.options[0].value,
selectedSubItem: null
};
},
beforeMount() {
this.handleChange()
},
watch: {
selected() {
this.handleChange();
}
},
methods: {
handleChange() {
if (this.value !== undefined) {
this.$emit("update:value", this.selected);
} else {
this.$emit("changed", this.selected);
}
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/variables.scss";
$radioSize: 16px;
$ui-border-width: 2px;
.sub-radios {
display: flex;
flex-direction: column;
flex: 0 0 100%;
margin-left: 1rem;
&:first-of-type {
margin-top: 1rem;
}
}
.radio {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 14px;
width: max-content;
input[type="radio"] {
display: block;
opacity: 0;
+ label {
position: relative;
display: inline-block;
cursor: pointer;
padding-left: 1.25rem;
font-weight: 300;
&::before {
content: "";
display: inline-block;
position: absolute;
left: -($radioSize / 4) * 4;
border-radius: 50%;
border: $ui-border-width solid $text-color-70;
width: $radioSize;
height: $radioSize;
}
&::after {
content: "";
position: absolute;
display: inline-block;
left: -($radioSize / 4) * 3;
top: $radioSize / 4;
border-radius: 50%;
width: ($radioSize / 4) * 3;
height: ($radioSize / 4) * 3;
}
}
&:checked,
&:hover {
+ label::after {
background-color: $green;
}
+ label::before {
border-color: $text-color;
}
}
&:focus {
+ label::before {
outline: $ui-border-width solid Highlight;
outline-style: auto;
outline-color: -webkit-focus-ring-color;
}
}
}
}
</style>

View File

@@ -31,7 +31,7 @@ export default {
font-size: 11px;
line-height: 2;
height: 45px;
letter-spacing: 1.2px;
letter-spacing: 0.5px;
padding: 5px 20px 4px 20px;
margin: 0;
margin-right: 0.3rem;

View File

@@ -70,7 +70,7 @@ export default {
.message {
width: 100%;
max-width: 35rem;
height: 75px;
min-height: 75px;
display: flex;
margin-top: 1rem;

View File

@@ -1,79 +0,0 @@
<template>
<div class="wrapper">
<h3 v-if="title" class="title">{{ title }}</h3>
<textarea :placeholder="placeholder" @input="handleInput" v-model="value" :rows="rows" />
</div>
</template>
<script>
export default {
props: {
placeholder: {
type: String,
required: false
},
title: {
type: String,
required: false
},
rows: {
type: Number,
required: false,
default: 10
},
value: {
type: String,
required: false,
default: undefined
}
},
methods: {
handleInput(event) {
if (this.value !== undefined) {
this.$emit('update:value', this.value)
} else {
this.$emit('input', this.value, event)
}
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables.scss";
.wrapper {
width: 100%;
}
.title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
font-size: 14px;
color: $green;
margin-bottom: 0.5rem;
@include tablet-min {
font-size: 16px;
}
}
textarea {
width: 100%;
font-size: 14px;
padding: 0.5rem;
border: 2px solid $text-color-50;
&:focus {
border-color: $text-color;
outline: none;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
}
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="toggle-container">
<button v-for="option in options" class="toggle-button" @click="toggle(option)"
:class="toggleValue === option ? 'selected' : null"
>{{ option }}</button>
</div>
</template>
<script>
export default {
props: {
options: {
Array,
required: true
},
selected: {
type: String,
required: false,
default: undefined
}
},
data() {
return {
toggleValue: this.selected || this.options[0]
}
},
beforeMount() {
this.toggle(this.toggleValue)
},
methods: {
toggle(toggleValue) {
this.toggleValue = toggleValue;
if (this.selected !== undefined) {
this.$emit('update:selected', toggleValue)
} else {
this.$emit('change', toggleValue)
}
}
},
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
$background: $background-ui;
$background-selected: $background-color-secondary;
.toggle-container {
width: 100%;
max-width: 15rem;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
// padding: 0.2rem;
background-color: $background;
border: 2px solid $background;
border-radius: 8px;
border-left: 4px solid $background;
border-right: 4px solid $background;
.toggle-button {
font-size: 1rem;
line-height: 1rem;
font-weight: normal;
width: 100%;
padding: 0.5rem 0;
border: 0;
color: $text-color;
// background-color: $text-color-5;
background-color: $background;
text-transform: capitalize;
&.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>

View File

@@ -11,13 +11,19 @@ export default {
data() {
return {
darkmode: window.getComputedStyle(document.body).colorScheme.includes('dark')
darkmode: this.supported
}
},
methods: {
toggleDarkmode() {
this.darkmode = !this.darkmode;
document.body.className = this.darkmode ? 'dark' : 'light'
},
supported() {
const computedStyle = window.getComputedStyle(document.body)
if (computedStyle['colorScheme'] != null)
return computedStyle.colorScheme.includes('dark')
return false
}
},
computed: {
@@ -41,7 +47,7 @@ export default {
margin-right: 2px;
bottom: 0;
right: 0;
z-index: 1;
z-index: 10;
-webkit-user-select: none;
-moz-user-select: none;

View File

@@ -1,18 +1,18 @@
<template>
<div>
<a @click="$emit('click')"><li>
<figure :class="activeClassIfActive" v-if="iconRefNameIfActive">
<svg class="icon">
<use :xlink:href="iconRefNameIfActive"/>
</svg>
</figure>
<a @click="$emit('click')">
<li>
<figure :class="activeClassIfActive">
<svg class="icon"><use :xlink:href="iconRefNameIfActive"/></svg>
</figure>
<span :class="activeClassIfActive">{{ contentTextToDisplay }}</span>
<span class="text" :class="activeClassIfActive">{{ contentTextToDisplay }}</span>
<span v-if="supplementaryText" class="supplementary-text">
{{ supplementaryText }}
</span>
</li></a>
<span v-if="supplementaryText" class="supplementary-text">
{{ supplementaryText }}
</span>
</li>
</a>
</div>
</template>
@@ -23,7 +23,7 @@ export default {
props: {
iconRef: {
type: String,
required: false
required: true
},
iconRefActive: {
type: String,
@@ -87,37 +87,51 @@ li {
&:hover {
color: $text-color-70;
cursor: pointer;
.icon {
fill: $text-color-70;
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, figure > .icon {
width: 18px;
height: 18px;
margin: 0 7px 0 0;
fill: $text-color-50;
transition: fill 0.5s ease, transform 0.5s ease;
&.waiting {
transform: scale(0.8, 0.8);
}
&.pending {
fill: #f8bd2d;
}
&:hover &-icon {
fill: $text-color-70;
cursor: pointer;
}
&.active > svg {
fill: $green;
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;
}
}
}
}

104
src/modules/userModule.js Normal file
View File

@@ -0,0 +1,104 @@
import { getSettings } from '@/api'
function setLocalStorageByKey(key, value) {
if (value instanceof Object || value instanceof Array) {
value = JSON.stringify(value)
}
const buff = Buffer.from(value)
const encodedValue = buff.toString('base64')
localStorage.setItem(key, encodedValue)
}
function getLocalStorageByKey(key) {
const encodedValue = localStorage.getItem(key)
if (encodedValue == null) {
return undefined
}
const buff = new Buffer(encodedValue, 'base64')
const value = buff.toString('utf-8')
try {
return JSON.parse(value)
} catch {
return value
}
}
const ifMissingSettingsAndTokenExistsFetchSettings =
() => getLocalStorageByKey('token') ? getSettings() : null
export default {
namespaced: true,
state: {
admin: false,
settings: undefined,
username: undefined,
plex_userid: undefined
},
getters: {
admin: (state) => {
return state.admin
},
settings: (state, foo, bar) => {
console.log('is this called?')
const settings = state.settings || getLocalStorageByKey('settings')
if (settings instanceof Object) {
return settings
}
ifMissingSettingsAndTokenExistsFetchSettings()
return undefined
},
username: (state) => {
const settings = state.settings || getLocalStorageByKey('settings')
if (settings instanceof Object && settings.hasOwnProperty('user_name')) {
return settings.user_name
}
ifMissingSettingsAndTokenExistsFetchSettings()
return undefined
},
plex_userid: (state) => {
const settings = state.settings || getLocalStorageByKey('settings')
console.log('plex_userid from store', settings)
if (settings instanceof Object && settings.hasOwnProperty('plex_userid')) {
return settings.plex_userid
}
ifMissingSettingsAndTokenExistsFetchSettings()
return undefined
}
},
mutations: {
SET_ADMIN: (state, isAdmin) => {
state.admin = isAdmin
},
SET_USERNAME: (state, username) => {
state.username = username
console.log('username')
setLocalStorageByKey('username', username)
},
SET_SETTINGS: (state, settings) => {
state.settings = settings
console.log('settings')
setLocalStorageByKey('settings', settings)
}
},
actions: {
setAdmin: ({commit}, isAdmin) => {
if (!(isAdmin instanceof Object)) {
throw "Parameter is not a boolean value."
}
commit('SET_ADMIN', isAdmin)
},
setSettings: ({commit}, settings) => {
console.log('settings input', settings)
if (!(settings instanceof Object)) {
throw "Parameter is not a object."
}
commit('SET_SETTINGS', settings)
}
}
}

View File

@@ -11,6 +11,11 @@ let routes = [
path: '/',
component: (resolve) => require(['./components/Home.vue'], resolve)
},
{
name: 'activity',
path: '/activity',
component: (resolve) => require(['./components/ActivityPage.vue'], resolve)
},
{
name: 'profile',
path: '/profile',

View File

@@ -21,4 +21,30 @@
> div:not(:first-child) {
margin-left: 1rem;
}
}
.flex {
display: flex;
&-direction-column {
flex-direction: column;
}
&-direction-row {
flex-direction: row;
}
&-align-items-center {
align-items: center;
}
}
.position {
&-relative {
position: relative;
}
&-absolute {
position: absolute;
}
}

View File

@@ -5,6 +5,31 @@ $tablet-p-width: 768px;
$tablet-l-width: 1024px;
$desktop-width: 1200px;
$desktop-l-width: 1600px;
$mobile-width: 768px;
@mixin desktop {
@media (min-width: #{$mobile-width + 1px}) {
@content;
}
}
@mixin mobile {
@media (max-width: #{$mobile-width}) {
@content;
}
}
.desktop-only {
@include mobile {
display: none;
}
}
.mobile-only {
@include desktop {
display: none;
}
}
// Media
@mixin mobile-only{

View File

@@ -11,13 +11,15 @@
--text-color-secondary: orange;
--background-color: #f8f8f8;
--background-color-secondary: #ffffff;
--background-ui: #edeef0;
--background-95: rgba(255, 255, 255, 0.95);
--background-70: rgba(255, 255, 255, 0.7);
--background-40: rgba(255, 255, 255, 0.4);
--background-nav-logo: #081c24;
--color-green: #01d277;
--color-green-90: rgba(1, 210, 119, .9);
--color-green-70: rgba(1, 210, 119, .73);
--color-teal: #091c24;
--color-black: #081c24;
--white: #fff;
@@ -42,11 +44,12 @@
--text-color-50: rgba(255, 255, 255, 0.5);
--text-color-5: rgba(255, 255, 255, 0.05);
--text-color-secondary: orange;
--background-color: #1e1f22;
--background-color-secondary: #111111;
--background-95: rgba(30, 31, 34, 0.95);
--background-70: rgba(30, 31, 34, 0.8);
--background-40: rgba(30, 31, 34, 0.4);
--background-color: rgba(17, 17, 17, 1);
--background-color-secondary: rgba(6, 7, 8, 1);
--background-ui: #202125;
--background-95: rgba(17, 17, 17, 0.95);
--background-70: rgba(17, 17, 17, 0.8);
--background-40: rgba(17, 17, 17, 0.4);
}
}
@@ -61,6 +64,7 @@ $header-size: var(--header-size);
$dark: rgb(30, 31, 34);
$green: var(--color-green);
$green-90: var(--color-green-90);
$green-70: var(--color-green-70);
$teal: #091c24;
$black: #081c24;
$black-80: rgba(0,0,0,0.8);
@@ -74,6 +78,7 @@ $text-color-5: var(--text-color-5) !default;
$text-color-secondary: var(--text-color-secondary) !default;
$background-color: var(--background-color) !default;
$background-color-secondary: var(--background-color-secondary) !default;
$background-ui: var(--background-ui) !default;
$background-95: var(--background-95) !default;
$background-70: var(--background-70) !default;
$background-40: var(--background-40) !default;
@@ -99,11 +104,12 @@ $color-error-highlight: var(--color-error-highlight) !default;
--text-color-50: rgba(255, 255, 255, 0.5);
--text-color-5: rgba(255, 255, 255, 0.05);
--text-color-secondary: orange;
--background-color: #1e1f22;
--background-color-secondary: #111111;
--background-95: rgba(30, 31, 34, 0.95);
--background-70: rgba(30, 31, 34, 0.7);
--color-teal: #091c24;
--background-color: rgba(17, 17, 17, 1);
--background-color-secondary: rgba(6, 7, 8, 1);
--background-ui: #202125;
--background-95: rgba(17, 17, 17, 0.95);
--background-70: rgba(17, 17, 17, 0.8);
--background-40: rgba(17, 17, 17, 0.4);
}
.light {
@@ -111,13 +117,11 @@ $color-error-highlight: var(--color-error-highlight) !default;
--text-color-70: rgba(8, 28, 36, 0.7);
--text-color-50: rgba(8, 28, 36, 0.5);
--text-color-5: rgba(8, 28, 36, 0.05);
--text-color-inverted: #fff;
--text-color-secondary: orange;
--background-color: #f8f8f8;
--background-color-secondary: #ffffff;
--background-ui: #edeef0;
--background-95: rgba(255, 255, 255, 0.95);
--background-70: rgba(255, 255, 255, 0.7);
--background-nav-logo: #081c24;
--color-green: #01d277;
--color-teal: #091c24;
--background-40: rgba(255, 255, 255, 0.4);
}

View File

@@ -1,17 +1,19 @@
import Vue from 'vue'
import Vuex from 'vuex'
import torrentModule from './modules/torrentModule'
import darkmodeModule from './modules/darkmodeModule'
import documentTitle from './modules/documentTitle'
import torrentModule from './modules/torrentModule'
import userModule from './modules/userModule'
Vue.use(Vuex)
const store = new Vuex.Store({
modules: {
torrentModule,
darkmodeModule,
documentTitle
documentTitle,
torrentModule,
userModule
}
})

View File

@@ -7,7 +7,17 @@ const sortableSize = (string) => {
const exponent = UNITS.indexOf(unit) * 3
return numStr * (Math.pow(10, exponent))
}
};
const parseJwt = (token) => {
var base64Url = token.split('.')[1];
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
var jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
};
export { sortableSize }
export { sortableSize, parseJwt }

View File

@@ -1729,6 +1729,29 @@ character-reference-invalid@^1.0.0:
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.3.tgz#1647f4f726638d3ea4a750cf5d1975c1c7919a85"
integrity sha512-VOq6PRzQBam/8Jm6XBGk2fNEnHXAdGd6go0rtd4weAGECBamHDwwCQSOT12TACIYUZegUXnV6xBXqUssijtxIg==
chart.js@^2.9.2:
version "2.9.2"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.2.tgz#5f7397f2fc33ca406836dbaed3cc39943bbb9f80"
integrity sha512-AagP9h27gU7hhx8F64BOFpNZGV0R1Pz1nhsi0M1+KLhtniX6ElqLl0z0obKSiuGMl9tcRe6ZhruCGCJWmH6snQ==
dependencies:
chartjs-color "^2.1.0"
moment "^2.10.2"
chartjs-color-string@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71"
integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==
dependencies:
color-name "^1.0.0"
chartjs-color@^2.1.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.4.1.tgz#6118bba202fe1ea79dd7f7c0f9da93467296c3b0"
integrity sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==
dependencies:
chartjs-color-string "^0.6.0"
color-convert "^1.9.3"
chokidar@^2.0.2, chokidar@^2.0.4, chokidar@^2.1.2:
version "2.1.8"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917"
@@ -1873,7 +1896,7 @@ collection-visit@^1.0.0:
map-visit "^1.0.0"
object-visit "^1.0.0"
color-convert@^1.3.0, color-convert@^1.9.0:
color-convert@^1.3.0, color-convert@^1.9.0, color-convert@^1.9.3:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
@@ -4660,6 +4683,11 @@ module-deps-sortable@5.0.0:
through2 "^2.0.0"
xtend "^4.0.0"
moment@^2.10.2:
version "2.24.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"