105 Commits

Author SHA1 Message Date
2665a27803 Lift mobile navigation some for chin height 2022-01-09 16:23:29 +01:00
74b96225c6 Testing out bottom align mobile navigation content 2022-01-09 16:00:02 +01:00
f180b7f39b Updated header text and font size 2022-01-09 15:58:04 +01:00
a2fbfcb13c Removed /dist prefix from built js file 2022-01-03 20:29:57 +01:00
d640f7f882 Removed /dist prefix from all image paths 2022-01-03 20:29:03 +01:00
d43c12b103 Prettierrc file 2022-01-03 17:50:55 +01:00
38c3792675 Add 'is-loaded' class after image intersects viewport 2022-01-03 17:50:12 +01:00
ac2785abd5 Increased opacity delay 2022-01-03 17:49:35 +01:00
1ff6a0e831 Linting 2022-01-03 17:49:22 +01:00
7a3b709404 Update router to use history not hash mode. 2021-05-18 10:21:00 +02:00
KevinMidboe
d63cb4ac52 Merge branch 'master' of github.com:kevinmidboe/seasoned 2020-04-09 23:01:25 +02:00
b6ee1cf906 Profile replaces route with query settings=true when enabled. 2020-04-09 23:00:50 +02:00
60201b1b67 Login and register pages now checks inputs for errors. throwError parameter on login and register functions allows us to receive the request object not just the decoded json. 2020-04-09 21:39:29 +02:00
a8b8603649 /login is alias of signin component. 2020-04-09 20:59:49 +02:00
e193528fe9 Routes with meta requiresAuth redirects to login page if token not set in localstorage 2020-04-09 20:58:58 +02:00
73afb34964 Logout route that clears localstorage for anything set clientside. 2020-04-09 20:53:24 +02:00
65bbc453e6 seasoned messages looks better when messages contains only title. 2020-04-09 20:27:11 +02:00
KevinMidboe
188477ab64 404 page now has button to navigate to previous page. 2020-04-09 19:58:50 +02:00
KevinMidboe
a31bfb6b39 Updated seasonedbutton to not have a wrapping div. 2020-04-09 19:55:57 +02:00
681ed69ef0 Removed padding on right side of search input and removed unused comment. 2020-02-25 13:44:48 +01:00
b771428b4d Changed placeholder for earch input 2020-02-25 13:44:31 +01:00
fc0103ee5d Change the document title prefix from request to seasoned 2020-02-25 12:12:07 +01:00
55067b81b8 Merge branch 'master' of github.com:KevinMidboe/seasoned 2020-02-25 12:09:45 +01:00
dfe2b5df09 Removed default emoji prefix of document title. 2020-02-25 12:09:13 +01:00
dc0c435163 If settings dont exist, return false for isAuthenticated. 2020-02-21 23:03:31 +01:00
9d1ac56b9a Also check localstorage for settings if not found in state. 2020-02-21 22:58:49 +01:00
fc2c3664d9 Resolved merge conflict. 2020-02-21 22:52:36 +01:00
0bd45ed777 New sidebarelement for users that are logged inn. Now they can be redirected directly to the movie in plex. 2020-02-21 22:51:39 +01:00
3912766982 Reverted active logic for seasonedButton. 2020-02-20 14:09:08 +01:00
3becce2a6c Moved isPlexAuthenticated from movie component to userModule. 2020-02-20 14:08:46 +01:00
20b8692c91 Forgot to toggle isActive when clicked. 2020-02-20 13:56:56 +01:00
14ac780aa5 Should not overwrite prop data. Copy and set to internal data attribute. 2020-02-20 13:55:04 +01:00
d836870612 Toggle active boolean to set class on buttons. 2020-02-20 13:41:39 +01:00
bc6f706e4a New mediaquery to check if hover is available then only style hover when it is. This solves sticky hover styling on mobile. 2020-02-20 13:33:08 +01:00
6ac6a9b039 Readded noselect class to description. 2020-02-20 10:41:46 +01:00
85be80d712 Removed unused code for poster image. 2020-02-20 10:41:21 +01:00
105be1e411 noselect was not the issue, bug in css-loader. 2020-02-20 00:47:55 +01:00
010830243e noselect class was preventing taps on mobile. 2020-02-20 00:25:59 +01:00
923dc46dc7 Removed setTimeout 2020-02-20 00:24:34 +01:00
f2ef5366f5 Merge pull request #48 from KevinMidboe/refactor/image-loading
Refactor/image loading
2020-02-20 00:22:14 +01:00
20380a4587 Merge branch 'master' into refactor/image-loading 2020-02-20 00:21:43 +01:00
069ef2c458 Cleaned up some css, better loading of backdrop, simplified DOM, more meta data for tvshows and added truncating of description. 2020-02-20 00:19:08 +01:00
2f430b2d8f Cleaned up some of the styling for movieslistitem. 2020-02-19 23:54:20 +01:00
f7a579a438 IntersecrionObserver checks ref intersection when mounted. 2020-02-19 23:53:51 +01:00
b9ddd998bc When type person show known for department. 2020-02-19 23:52:25 +01:00
ae59d02df2 Poster image dom simplified. 2020-02-19 23:52:03 +01:00
ec205bab0c Update .drone.yml 2020-02-07 01:19:08 +01:00
ed49d825b8 Merge pull request #44 from KevinMidboe/feature/searchFiltering
Feature/search filtering
2020-01-31 22:51:21 +01:00
a9db8be46a Removed duplicated top_rated icon. 2020-01-31 22:32:12 +01:00
1caa3c7fae Removed unsued comments and added alt tag to images 2020-01-31 22:27:45 +01:00
2ea4bffd49 Update .drone.yml 2020-01-31 22:21:49 +01:00
5ae52f59fc Merge pull request #47 from KevinMidboe/feature/lazy-loading-images
Lazy loading for list items.
2020-01-31 22:18:42 +01:00
a7e6d25d3f Lazy loading for list items.
This is somewhat inefficient because each list item has its own instance
of a intersectionObserver.
Improvements include:
- Poster has placeholder image as source from mount
- When component mounts we attach the observer
- When observerd in viewport find
  - Find the correct image height based on the placeholders height
  - Change src to dynamic poster url
2020-01-31 22:14:13 +01:00
83751a4e3e Update .drone.yml 2020-01-20 19:15:42 +01:00
0e9daab187 Removed unused raven link under body 2020-01-20 19:10:45 +01:00
4390491873 Update .drone.yml 2020-01-20 19:07:48 +01:00
d620a4cc2e Update .drone.yml 2020-01-20 19:07:00 +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
1fd48edd42 Set default adult value to true. 2019-12-27 23:31:20 +01:00
68e45303c6 Filtering for search in autocomplete dropdown.
- Accessibility
 - Tabindex updated for search <input> to have priority over nav items.
 - Aria label
- Search icon clickable for searching.
- Filter for adult and searchType.
- When clicking a autocomplete search result, the clicked item is set as
selectedResult.
- Remove duplicates from elastic search result.
- Added filter parameters to our $router.push function.
2019-12-27 22:18:45 +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
36 changed files with 2530 additions and 1606 deletions

44
.drone.yml Normal file
View File

@@ -0,0 +1,44 @@
---
kind: pipeline
type: docker
name: seasoned build
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
event:
include:
- pull_request
- push

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"vueIndentScriptAndStyle": false,
"trailingComma": "none"
}

View File

@@ -21,10 +21,6 @@
<symbol id="icon_now_playing" viewBox="0 0 30 30">
<title>Now Playing</title>
<path d="M27.9847266,7.50322266 C25.9822852,4.03494141 22.749082,1.55390625 18.8806055,0.517382812 C15.0121875,-0.519257812 10.9716797,0.0127148437 7.50322266,2.01527344 C4.03482422,4.01777344 1.55390625,7.25097656 0.517382812,11.1194531 C-0.519140625,14.9878711 0.0128320312,19.0284961 2.01527344,22.4967773 C4.01765625,25.9650586 7.25097656,28.4460937 11.1193945,29.4826172 C12.4111523,29.8287891 13.7219531,30 15.0244336,30 C17.6224219,30 20.1866016,29.3186133 22.4968359,27.9847852 C25.9651172,25.9823437 28.4461523,22.7491406 29.4826758,18.8806641 C30.5192578,15.0121289 29.987168,10.9716211 27.9847266,7.50322266 Z M27.9743555,18.476543 C27.0457617,21.9421289 24.8231836,24.8387109 21.715957,26.6326172 C18.6088477,28.426582 14.989043,28.9030664 11.523457,27.9745898 C8.0578125,27.0459961 5.16128906,24.823418 3.36732422,21.7161914 C1.57341797,18.609082 1.096875,14.9892188 2.02552734,11.5235742 C2.95417969,8.05798828 5.17675781,5.16152344 8.28392578,3.3675 C10.35375,2.17248047 12.6505664,1.56210937 14.9782031,1.56210937 C16.1448047,1.56210937 17.3195508,1.71550781 18.4763672,2.02552734 C21.9419531,2.95412109 24.8385352,5.17669922 26.6324414,8.28392578 C28.4264063,11.3910937 28.9030078,15.0108984 27.9743555,18.476543 Z M22.1940234,13.5850781 L12.5538281,8.01925781 C12.0422461,7.72388672 11.4314648,7.72400391 10.9198828,8.01919922 C10.4083008,8.31451172 10.1028516,8.84355469 10.1028516,9.43423828 L10.1028516,20.5658789 C10.1028516,21.1565625 10.4082422,21.6855469 10.9198828,21.980918 C11.1756445,22.1286328 11.4561328,22.2024023 11.7367383,22.2024023 C12.0174023,22.2024023 12.2980078,22.128457 12.5537695,21.9808594 L22.194082,16.4150977 C22.7056055,16.119668 23.0109375,15.5906836 23.0109375,15 C23.0109375,14.409375 22.7055469,13.8803906 22.1940234,13.5850781 Z M21.4132031,15.0629297 L11.7729492,20.6286914 C11.7611719,20.6355469 11.7366211,20.649668 11.7005273,20.6286914 C11.6643164,20.6077734 11.6643164,20.5795312 11.6643164,20.5659375 L11.6643164,9.43429687 C11.6643164,9.42070312 11.6643164,9.39246094 11.7005273,9.37154297 C11.714707,9.36333984 11.7270703,9.36052734 11.7376172,9.36052734 C11.7540234,9.36052734 11.7658594,9.36738281 11.7730664,9.37154297 L21.4132617,14.9373633 C21.4250391,14.9441602 21.4494727,14.9582812 21.4494727,15.0001172 C21.4494727,15.0419531 21.4249219,15.0561328 21.4132031,15.0629297 Z M24.2169727,7.87734375 C22.3601953,5.47863281 19.5689648,3.86707031 16.5588867,3.45580078 C16.1321484,3.39738281 15.7380469,3.69638672 15.6796289,4.12371094 C15.6213281,4.55091797 15.920332,4.94455078 16.3475391,5.00296875 C18.9556641,5.35927734 21.3738867,6.75544922 22.9822266,8.83318359 C23.1360937,9.03193359 23.3668945,9.13599609 23.6001562,9.13599609 C23.7670898,9.13599609 23.9353125,9.08273437 24.0774609,8.97257813 C24.418418,8.70867187 24.4808789,8.21830078 24.2169727,7.87734375 Z" fill-rule="nonzero"></path>
</symbol>
<symbol id="icon_top_rated" viewBox="0 0 30 30">
<title>Top Rated</title>
<path d="M24.7750847,5.22491532 C24.7021599,5.15199056 24.6169531,5.09595364 24.52407,5.05757218 C24.4304192,5.01919073 24.3313951,5 24.2323709,5 L8.84447835,5.00076763 C8.41997947,5.00076763 8.07684927,5.34466546 8.07684927,5.76839671 C8.07684927,6.19289559 8.4207471,6.53602579 8.84447835,6.53602579 L22.3785467,6.53525816 L5.22510723,23.6894653 C4.92496426,23.9896082 4.92496426,24.4747498 5.22510723,24.7748928 C5.3747949,24.9245804 5.57130794,24.9998081 5.76782099,24.9998081 C5.96433403,24.9998081 6.16084708,24.9245804 6.31053475,24.7748928 L23.4647418,7.62068568 L23.4647418,21.1539864 C23.4647418,21.5784853 23.807872,21.9216155 24.2323709,21.9216155 C24.6568698,21.9216155 25,21.5784853 25,21.1539864 L25,5.76762908 C25,5.66860493 24.9808093,5.56958078 24.9424278,5.47593003 C24.9040464,5.38304691 24.8480094,5.29784008 24.7750847,5.22491532 Z"></path>
</symbol>
<symbol id="icon_popular" viewBox="0 0 30 30">
<title>Popular</title>
@@ -125,9 +121,8 @@
l246.17-246.175C512.959,136.021,512.959,129.804,509.121,125.966z" fill-rule="nozero" transform="scale(1.4)"></path>
</symbol>
<div id="app"></div>
<script type="text/javascript" src="dist/build.js"></script>
<script type="text/javascript" src="/build.js"></script>
</body>
<script src="https://cdn.ravenjs.com/3.23.1/vue/raven.min.js" crossorigin="anonymous"></script>
<!-- <script>Raven.config('https://c1fa1a17de3d4b24abcd05161648fe4d@sentry.io/300063').install();</script> -->
</html>

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",
@@ -29,7 +30,7 @@
"@babel/runtime": "^7.4.5",
"babel-loader": "^8.0.6",
"cross-env": "^3.0.0",
"css-loader": "^0.25.0",
"css-loader": "^3.4.2",
"documentation": "^11.0.0",
"file-loader": "^0.9.0",
"node-sass": "^4.5.0",

View File

@@ -1,37 +1,32 @@
<template>
<div id="app">
<!-- Header and hamburger navigation -->
<navigation></navigation>
<!-- Header with search field -->
<!-- TODO move this to the navigation component -->
<header class="header">
<search-input v-model="query"></search-input>
</header>
<search-input v-model="query"></search-input>
<!-- Movie popup that will show above existing rendered content -->
<movie-popup v-if="moviePopupIsVisible" :id="popupID" :type="popupType"></movie-popup>
<movie-popup
v-if="moviePopupIsVisible"
:id="popupID"
:type="popupType"
></movie-popup>
<darkmode-toggle />
<!-- Display the component assigned to the given route (default: home) -->
<router-view class="content" :key="$route.fullPath"></router-view>
</div>
</template>
<script>
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 Vue from "vue";
import Navigation from "@/components/Navigation";
import MoviePopup from "@/components/MoviePopup";
import SearchInput from "@/components/SearchInput";
import DarkmodeToggle from "@/components/ui/darkmodeToggle";
export default {
name: 'app',
name: "app",
components: {
Navigation,
MoviePopup,
@@ -40,39 +35,42 @@ export default {
},
data() {
return {
query: '',
query: "",
moviePopupIsVisible: false,
popupID: 0,
popupType: 'movie'
}
popupType: "movie"
};
},
created(){
let that = this
created() {
let that = this;
Vue.prototype.$popup = {
get isOpen() {
return that.moviePopupIsVisible
return that.moviePopupIsVisible;
},
open: (id, type) => {
this.popupID = id || this.popupID
this.popupType = type || this.popupType
this.moviePopupIsVisible = true
console.log('opened')
this.popupID = id || this.popupID;
this.popupType = type || this.popupType;
this.moviePopupIsVisible = true;
console.log("opened");
},
close: () => {
this.moviePopupIsVisible = false
console.log('closed')
this.moviePopupIsVisible = false;
console.log("closed");
}
}
console.log('MoviePopup registered at this.$popup and has state: ', this.$popup.isOpen)
};
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{
@include tablet-min {
width: calc(100% - 95px);
margin-top: $header-size;
margin-left: 95px;
@@ -86,38 +84,42 @@ export default {
@import "./src/scss/variables";
@import "./src/scss/media-queries";
*{
* {
box-sizing: border-box;
}
html {
height: 100%;
}
body{
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
font-family: "Roboto", sans-serif;
line-height: 1.6;
background: $background-color;
color: $text-color;
transition: background-color .5s ease, color .5s ease;
&.hidden{
transition: background-color 0.5s ease, color 0.5s ease;
&.hidden {
overflow: hidden;
}
}
h1,h2,h3 {
transition: color .5s ease;
h1,
h2,
h3 {
transition: color 0.5s ease;
}
a:any-link {
color: inherit;
}
input, textarea, button{
font-family: 'Roboto', sans-serif;
input,
textarea,
button {
font-family: "Roboto", sans-serif;
}
figure{
figure {
padding: 0;
margin: 0;
}
img{
img {
display: block;
// max-width: 100%;
height: auto;
@@ -127,16 +129,16 @@ img{
overflow: hidden;
}
.wrapper{
.wrapper {
position: relative;
}
.header{
.header {
position: fixed;
z-index: 15;
display: flex;
flex-direction: column;
@include tablet-min{
@include tablet-min {
width: calc(100% - 170px);
margin-left: 95px;
border-top: 0;
@@ -146,14 +148,16 @@ img{
}
// router view transition
.fade-enter-active, .fade-leave-active {
.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
.fade-enter,
.fade-leave-active {
opacity: 0;
}
</style>

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,156 @@ const getRequestStatus = (id, type, authorization_token=undefined) => {
.catch(err => Promise.reject(err))
}
// - - - Authenticate with plex - - -
const plexAuthenticate = (username, password) => {
const url = new URL('https://plex.tv/api/v2/users/signin')
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 = {
'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'
'Authorization': authorization_token,
'Content-Type': 'application/json'
}
let formData = new FormData()
formData.set('login', username)
formData.set('password', password)
formData.set('rememberMe', false)
return fetch(url.href, { headers })
.then(resp => resp.json())
.then(response => response.link)
}
return axios({
method: 'POST',
url: url.href,
headers: headers,
data: formData
// - - - 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
})
.catch(error => { console.error(`api error authentication plex: ${username}`); 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 })
}
@@ -302,6 +468,7 @@ const elasticSearchMoviesAndShows = (query) => {
export {
getMovie,
getShow,
getPerson,
getTmdbMovieListByName,
searchTmdb,
getUserRequests,
@@ -309,8 +476,15 @@ export {
searchTorrents,
addMagnet,
request,
watchLink,
getRequestStatus,
plexAuthenticate,
linkPlexAccount,
unlinkPlexAccount,
register,
login,
getSettings,
updateSettings,
fetchChart,
getEmoji,
elasticSearchMoviesAndShows
}

View File

@@ -1,38 +1,74 @@
<template>
<section class="not-found">
<h1 class="not-found__title">Page Not Found</h1>
</section>
<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));
width: 100%;
background: url('~assets/pulp-fiction.jpg') no-repeat 50% 50%;
background-size: cover;
justify-content: center;
align-items: center;
flex-direction: column;
&:before {
&::before {
content: "";
position: absolute;
height: calc(100vh - var(--header-size));
width: 100%;
pointer-events: none;
background: $background-40;
}
&__title {
padding-top: 40vh;
font-size: 2rem;
margin-top: 30vh;
font-size: 2.5rem;
font-weight: 500;
color: $text-color;
position: relative;
margin: 0;
@include tablet-min {
font-size: 2.3rem;
font-size: 3.5rem;
}
}
}

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

@@ -12,74 +12,76 @@
</template>
<script>
import LandingBanner from '@/components/LandingBanner'
import ListHeader from '@/components/ListHeader'
import ResultsList from '@/components/ResultsList'
import Loader from '@/components/ui/Loader'
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'
import { getTmdbMovieListByName, getRequests } from "@/api";
export default {
name: 'home',
name: "home",
components: { LandingBanner, ResultsList, ListHeader, Loader },
data(){
data() {
return {
imageFile: 'dist/pulp-fiction.jpg',
imageFile: "/pulp-fiction.jpg",
requests: [],
nowplaying: [],
upcoming: [],
popular: []
}
};
},
computed: {
lists() {
return [
{
title: 'Requests',
route: 'request',
title: "Requests",
route: "request",
data: this.requests
},
{
title: 'Now playing',
route: 'now_playing',
title: "Now playing",
route: "now_playing",
data: this.nowplaying
},
{
title: 'Upcoming',
route: 'upcoming',
title: "Upcoming",
route: "upcoming",
data: this.upcoming
},
{
title: 'Popular',
route: 'popular',
title: "Popular",
route: "popular",
data: this.popular
}
]
];
}
},
methods: {
fetchRequests() {
getRequests()
.then(results => this.requests = results.results)
getRequests().then(results => (this.requests = results.results));
},
fetchNowPlaying() {
getTmdbMovieListByName('now_playing')
.then(results => this.nowplaying = results.results)
getTmdbMovieListByName("now_playing").then(
results => (this.nowplaying = results.results)
);
},
fetchUpcoming() {
getTmdbMovieListByName('upcoming')
.then(results => this.upcoming = results.results)
getTmdbMovieListByName("upcoming").then(
results => (this.upcoming = results.results)
);
},
fetchPopular() {
getTmdbMovieListByName('popular')
.then(results => this.popular = results.results)
getTmdbMovieListByName("popular").then(
results => (this.popular = results.results)
);
}
},
created(){
this.fetchRequests()
this.fetchNowPlaying()
this.fetchUpcoming()
this.fetchPopular()
created() {
this.fetchRequests();
this.fetchNowPlaying();
this.fetchUpcoming();
this.fetchPopular();
}
}
};
</script>

View File

@@ -1,8 +1,10 @@
<template>
<header 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>
</header>
</template>
@@ -17,15 +19,15 @@ export default {
},
data() {
return {
imageFile: 'dist/pulp-fiction.jpg'
}
imageFile: "/pulp-fiction.jpg"
};
},
beforeMount() {
if (this.image && this.image.length > 0) {
this.imageFile = this.image
this.imageFile = this.image;
}
}
}
};
</script>
<style lang="scss" scoped>
@@ -55,15 +57,15 @@ header {
width: 100%;
height: 100%;
background-color: $background-70;
transition: background-color .5s ease;
transition: background-color 0.5s ease;
}
.container {
text-align: center;
position: relative;
transition: color .5s ease;
transition: color 0.5s ease;
}
.title {
font-weight: 500;
font-size: 22px;
@@ -72,8 +74,8 @@ header {
color: $text-color;
margin: 0;
@include tablet-min{
font-size: 28px;
@include tablet-min {
font-size: 2.5rem;
}
}
@@ -84,9 +86,9 @@ header {
color: $text-color-70;
margin: 5px 0;
@include tablet-min{
font-size: 16px;
@include tablet-min {
font-size: 1.3rem;
}
}
}
</style>
</style>

View File

@@ -1,12 +1,20 @@
<template>
<header :class="{ 'sticky': sticky }">
<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>
</header>
</template>
<script>
@@ -19,10 +27,10 @@ export default {
sticky: {
type: Boolean,
required: false,
default: false
default: true
},
info: {
type: String,
type: [String, Array],
required: false
},
link: {
@@ -30,72 +38,80 @@ export default {
required: false
}
}
}
};
</script>
<style lang="scss" scoped>
@import './src/scss/variables';
@import './src/scss/media-queries';
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/main";
header {
width: 100%;
min-height: 45px;
display: flex;
justify-content: space-between;
padding: 1.8rem 12px;
align-items: center;
padding-left: 0.75rem;
padding-right: 0.75rem;
@include tablet-min {
min-height: 65px;
}
&.sticky {
background-color: $background-color;
position: sticky;
position: -webkit-sticky;
top: $header-size;
top: 0;
z-index: 4;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
@include tablet-min {
top: $header-size;
}
}
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;
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;
}
}
.result-count {
.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,7 +1,6 @@
<template>
<div>
<list-header :title="listTitle" :info="resultCount" :sticky="true" />
<div class="page-container">
<list-header :title="listTitle" :info="info" :sticky="true" />
<results-list :results="results" v-if="results" />
@@ -13,87 +12,107 @@
</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'
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' ],
legalTmdbLists: ["now_playing", "upcoming", "popular"],
results: [],
page: 1,
totalPages: 0,
totalResults: 0
}
totalResults: 0,
loading: true
};
},
computed: {
listTitle() {
if (this.results.length === 0)
return ''
if (this.results.length === 0) return "";
const routeListName = this.$route.params.name
console.log('routelistname', routeListName)
return routeListName.includes('_') ? routeListName.split('_').join(' ') : routeListName
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() {
if (this.results.length === 0)
return ''
const loadedResults = this.results.length
const totalResults = this.totalResults < 10000 ? this.totalResults : '∞'
return `${loadedResults} of ${totalResults} results`
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.page++
console.log(this.$route);
this.loading = true;
this.page++;
window.history.replaceState({}, 'search', `/#/${this.$route.fullPath}?page=${this.page}`)
this.init()
window.history.replaceState(
{},
"search",
`/#/${this.$route.fullPath}?page=${this.page}`
);
this.init();
},
init() {
const routeListName = this.$route.params.name
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
})
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
})
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')
console.log("404 this is not a tmdb list");
}
this.loading = false;
}
},
created() {
if (this.results.length === 0)
this.init()
if (this.results.length === 0) this.init();
store.dispatch('documentTitle/updateTitle', `${this.$router.history.current.name} ${this.$route.params.name}`)
store.dispatch(
"documentTitle/updateTitle",
`${this.$router.history.current.name} ${this.$route.params.name}`
);
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/media-queries";
@include mobile-only {
.page-container {
margin-top: 1rem;
}
}
.fullwidth-button {
width: 100%;
margin: 1rem 0;

View File

@@ -1,52 +1,60 @@
<template>
<section class="movie">
<!-- HEADER w/ POSTER -->
<header class="movie__header" :style="{ 'background-image': movie && backdrop !== null ? 'url(' + ASSET_URL + ASSET_SIZES[1] + backdrop + ')' : '' }" :class="compact ? 'compact' : ''" @click="compact=!compact">
<div class="movie__wrap movie__wrap--header">
<figure class="movie__poster">
<img v-if="movie && poster === null"
class="movies-item__img is-loaded"
alt="movie poster image"
src="~assets/no-image.png">
<img v-else-if="poster === undefined"
class="movies-item__img grey"
alt="movie poster image">
<!-- src="~assets/placeholder.png"> -->
<img v-else
class="movies-item__img is-loaded"
alt="movie poster image"
:src="ASSET_URL + ASSET_SIZES[0] + poster">
</figure>
<header
ref="header"
:class="compact ? 'compact' : ''"
@click="compact = !compact"
>
<figure class="movie__poster">
<img
class="movie-item__img is-loaded"
ref="poster-image"
src="~assets/placeholder.png"
/>
</figure>
<div class="movie__title">
<h1 v-if="movie">{{ movie.title }}</h1>
<loading-placeholder v-else :count="1" />
</div>
</div>
<h1 class="movie__title" v-if="movie">{{ movie.title }}</h1>
<loading-placeholder v-else :count="1" />
</header>
<!-- Siderbar and movie info -->
<div class="movie__main">
<div class="movie__wrap movie__wrap--main">
<!-- SIDEBAR ACTIONS -->
<div class="movie__actions" v-if="movie">
<sidebar-list-element :iconRef="'#iconNot_exsits'" :active="matched"
:iconRefActive="'#iconExists'" :textActive="'Already in plex 🎉'">
<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'">
<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">
<sidebar-list-element
v-if="isPlexAuthenticated && matched"
@click="openInPlex"
:iconString="'⏯ '"
>
Watch in plex now!
</sidebar-list-element>
<sidebar-list-element
v-if="admin"
@click="showTorrents = !showTorrents"
:iconRef="'#icon_torrents'"
:active="showTorrents"
:supplementaryText="numberOfTorrentResults"
>
Search for torrents
</sidebar-list-element>
<sidebar-list-element @click="openTmdb" :iconRef="'#icon_info'">
@@ -56,83 +64,129 @@
<!-- Loading placeholder -->
<div class="movie__actions text-input__loading" v-else>
<div class="movie__actions-link" v-for="_ in admin ? Array(4) : Array(3)">
<div class="movie__actions-text text-input__loading--line" style="margin:9px; margin-left: -3px;"></div>
<div
class="movie__actions-link"
v-for="_ in admin ? Array(4) : Array(3)"
>
<div
class="movie__actions-text text-input__loading--line"
style="margin: 9px; margin-left: -3px"
></div>
</div>
</div>
<!-- MOVIE INFO -->
<div class="movie__info">
<div class="movie__description" v-if="movie"> {{ movie.overview }}</div>
<!-- Loading placeholder -->
<div
class="movie__description noselect"
@click="truncatedDescription = !truncatedDescription"
v-if="!loading"
>
<span :class="truncatedDescription ? 'truncated' : null">{{
movie.overview
}}</span>
<button class="truncate-toggle"><i></i></button>
</div>
<div v-else class="movie__description">
<loading-placeholder :count="12" />
<loading-placeholder :count="5" />
</div>
<div class="movie__details" v-if="movie">
<div v-if="movie.year" class="movie__details-block">
<h2 class="movie__details-title">Release Date</h2>
<div class="movie__details-text">{{ movie.year }}</div>
<div v-if="movie.year">
<h2 class="title">Release Date</h2>
<div class="text">{{ movie.year }}</div>
</div>
<div v-if="movie.rank" class="movie__details-block">
<h2 class="movie__details-title">Rating</h2>
<div class="movie__details-text">{{ movie.rank }}</div>
<div v-if="movie.rating">
<h2 class="title">Rating</h2>
<div class="text">{{ movie.rating }}</div>
</div>
<div v-if="movie.type == 'show'" class="movie__details-block">
<h2 class="movie__details-title">Seasons</h2>
<div class="movie__details-text">{{ movie.seasons }}</div>
<div v-if="movie.type == 'show'">
<h2 class="title">Seasons</h2>
<div class="text">{{ movie.seasons }}</div>
</div>
<div v-if="movie.genres" class="movie__details-block">
<h2 class="movie__details-title">Genres</h2>
<div class="movie__details-text">{{ nestedDataToString(movie.genres) }}</div>
<div v-if="movie.genres">
<h2 class="title">Genres</h2>
<div class="text">{{ movie.genres.join(", ") }}</div>
</div>
<div v-if="movie.type == 'show'">
<h2 class="title">Production status</h2>
<div class="text">{{ movie.production_status }}</div>
</div>
<div v-if="movie.type == 'show'">
<h2 class="title">Runtime</h2>
<div class="text">{{ movie.runtime[0] }} minutes</div>
</div>
</div>
</div>
<!-- TODO: change this classname, this is general -->
<div class="movie__admin" v-if="movie && movie.credits">
<h2 class="movie__details-title">Cast</h2>
<div style="display: flex; flex-wrap: wrap;">
<person v-for="cast in movie.credits.cast" :info="cast"
style="flex-basis: 0;"></person>
</div>
<div style="display: flex; flex-wrap: wrap">
<person
v-for="cast in movie.credits.cast"
:info="cast"
style="flex-basis: 0"
></person>
</div>
</div>
</div>
<!-- TORRENT LIST -->
<TorrentList v-if="movie" :show="showTorrents" :query="title" :tmdb_id="id"
:admin="admin"></TorrentList>
<TorrentList
v-if="movie"
:show="showTorrents"
:query="title"
:tmdb_id="id"
:admin="admin"
></TorrentList>
</div>
</section>
</template>
<script>
import storage from '@/storage'
import img from '@/directives/v-image'
import TorrentList from './TorrentList'
import Person from './Person'
import SidebarListElement from './ui/sidebarListElem'
import store from '@/store'
import LoadingPlaceholder from './ui/LoadingPlaceholder'
import storage from "@/storage";
import img from "@/directives/v-image";
import TorrentList from "./TorrentList";
import Person from "./Person";
import SidebarListElement from "./ui/sidebarListElem";
import store from "@/store";
import LoadingPlaceholder from "./ui/LoadingPlaceholder";
import { getMovie, getShow, request, getRequestStatus } from '@/api'
import {
getMovie,
getPerson,
getShow,
request,
getRequestStatus,
watchLink
} from "@/api";
export default {
props: ['id', 'type'],
// props: ['id', 'type'],
props: {
id: {
required: true,
type: Number
},
type: {
required: false,
type: String
}
},
components: { TorrentList, Person, LoadingPlaceholder, SidebarListElement },
directives: { img: img }, // TODO decide to remove or use
data(){
return{
ASSET_URL: 'https://image.tmdb.org/t/p/',
ASSET_SIZES: ['w500', 'w780', 'original'],
data() {
return {
ASSET_URL: "https://image.tmdb.org/t/p/",
ASSET_SIZES: ["w500", "w780", "original"],
movie: undefined,
title: undefined,
poster: undefined,
@@ -140,88 +194,201 @@ 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
}
},
methods: {
parseResponse(movie) {
this.movie = { ...movie }
this.title = movie.title
this.poster = movie.poster
this.backdrop = movie.backdrop
this.matched = movie.existsInPlex
this.checkIfRequested(movie)
.then(status => this.requested = status)
store.dispatch('documentTitle/updateTitle', movie.title)
},
async checkIfRequested(movie) {
return await getRequestStatus(movie.id, movie.type)
},
nestedDataToString(data) {
let nestedArray = []
data.forEach(item => nestedArray.push(item));
return nestedArray.join(', ');
},
sendRequest(){
request(this.id, this.type, storage.token)
.then(resp => {
if (resp.success) {
this.requested = true
}
})
},
openTmdb(){
const tmdbType = this.type === 'show' ? 'tv' : this.type
window.location.href = 'https://www.themoviedb.org/' + tmdbType + '/' + this.id
},
compact: false,
loading: true,
truncatedDescription: true
};
},
watch: {
id: function(val){
if (this.type === 'movie') {
id: function (val) {
if (this.type === "movie") {
this.fetchMovie(val);
} else {
this.fetchShow(val)
this.fetchShow(val);
}
},
backdrop: function (backdrop) {
if (backdrop != null) {
const style = {
backgroundImage:
"url(" + this.ASSET_URL + this.ASSET_SIZES[1] + backdrop + ")"
};
Object.assign(this.$refs.header.style, style);
}
}
},
computed: {
numberOfTorrentResults: () => {
let numTorrents = store.getters['torrentModule/resultCount']
return numTorrents !== null ? numTorrents + ' results' : null
let numTorrents = store.getters["torrentModule/resultCount"];
return numTorrents !== null ? numTorrents + " results" : null;
},
isPlexAuthenticated: () => {
return store.getters["userModule/isPlexAuthenticated"];
}
},
methods: {
parseResponse(movie) {
this.loading = false;
this.movie = { ...movie };
this.title = movie.title;
this.poster = movie.poster;
this.backdrop = movie.backdrop;
this.matched = movie.exists_in_plex || false;
this.checkIfRequested(movie).then(status => (this.requested = status));
store.dispatch("documentTitle/updateTitle", movie.title);
this.setPosterSrc();
},
async checkIfRequested(movie) {
return await getRequestStatus(movie.id, movie.type);
},
setPosterSrc() {
const poster = this.$refs["poster-image"];
if (this.poster == null) {
poster.src = "/no-image.png";
return;
}
poster.src = `${this.ASSET_URL}${this.ASSET_SIZES[0]}${this.poster}`;
},
sendRequest() {
request(this.id, this.type, storage.token).then(resp => {
if (resp.success) {
this.requested = true;
}
});
},
openInPlex() {
watchLink(this.title, this.movie.year, storage.token).then(
watchLink => (window.location = watchLink)
);
},
openTmdb() {
const tmdbType = this.type === "show" ? "tv" : this.type;
window.location.href =
"https://www.themoviedb.org/" + tmdbType + "/" + this.id;
}
},
created() {
this.prevDocumentTitle = store.getters["documentTitle/title"];
if (this.type === "movie") {
getMovie(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
} else if (this.type == "person") {
getPerson(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
} else {
getShow(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
}
},
beforeDestroy() {
store.dispatch('documentTitle/updateTitle', this.prevDocumentTitle)
},
created(){
this.prevDocumentTitle = store.getters['documentTitle/title']
if (this.type === 'movie') {
getMovie(this.id)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: '404' });
})
} else {
getShow(this.id)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: '404' });
})
}
console.log('admin: ', this.admin)
store.dispatch("documentTitle/updateTitle", this.prevDocumentTitle);
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/loading-placeholder";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/main";
header {
$duration: 0.2s;
height: 250px;
transform: scaleY(1);
transition: height $duration ease;
transform-origin: top;
position: relative;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
background-color: $background-color;
display: flex;
align-items: center;
@include tablet-min {
height: 350px;
}
&:before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
background: $background-dark-85;
}
@include mobile {
&.compact {
height: 100px;
}
}
}
.movie__poster {
display: none;
@include desktop {
background: $background-color;
height: 0;
display: block;
position: absolute;
width: calc(45% - 40px);
top: 40px;
left: 40px;
> img {
width: 100%;
}
}
}
.truncate-toggle {
border: none;
background: none;
width: 100%;
display: flex;
align-items: center;
text-align: center;
color: $text-color;
> i {
font-style: unset;
font-size: 0.7rem;
transition: 0.3s ease all;
transform: rotateY(180deg);
}
&::before,
&::after {
content: "";
flex: 1;
border-bottom: 1px solid $text-color-50;
}
&::before {
margin-right: 1rem;
}
&::after {
margin-left: 1rem;
}
}
.movie {
&__wrap {
@@ -234,7 +401,7 @@ export default {
display: flex;
flex-wrap: wrap;
flex-direction: column;
@include tablet-min{
@include tablet-min {
flex-direction: row;
}
@@ -242,49 +409,6 @@ export default {
color: $text-color;
}
}
&__header {
$duration: 0.2s;
height: 250px;
transform: scaleY(1);
transition: height $duration ease;
transform-origin: top;
position: relative;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
background-color: $background-color;
@include tablet-min {
height: 350px;
}
&:before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
background: $background-dark-85;
}
&.compact {
height: 100px;
}
}
&__poster {
display: none;
@include tablet-min {
background: $background-color;
height: 0;
display: block;
position: absolute;
width: calc(45% - 40px);
top: 40px;
left: 40px;
}
}
&__img {
display: block;
@@ -328,56 +452,67 @@ export default {
height: 100%;
}
&__actions {
text-align: center;
width: 100%;
order: 2;
padding: 20px;
border-top: 1px solid $text-color-5;
@include tablet-min {
order: 1;
width: 45%;
padding: 185px 0 40px 40px;
border-top: 0;
}
}
&__info {
width: 100%;
padding: 20px;
&__actions {
text-align: center;
width: 100%;
order: 2;
padding: 20px;
border-top: 1px solid $text-color-5;
@include tablet-min {
order: 1;
@include tablet-min {
order: 2;
padding: 40px;
width: 55%;
margin-left: 45%;
width: 45%;
padding: 185px 0 40px 40px;
border-top: 0;
}
}
&__info {
width: 100%;
padding: 20px;
order: 1;
@include tablet-min {
order: 2;
padding: 40px;
width: 55%;
margin-left: 45%;
}
}
&__info {
margin-left: 0;
}
&__description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
& .truncated {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
& + .truncate-toggle > i {
transform: rotateY(0deg) rotateZ(180deg);
}
}
&__info {
margin-left: 0;
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
&__description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
}
&__details {
display: flex;
flex-wrap: wrap;
> div {
margin-bottom: 20px;
margin-right: 20px;
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
margin-right: 30px;
}
}
&__details {
&-block {
float: left;
}
&-block:not(:last-child) {
margin-bottom: 20px;
margin-right: 20px;
@include tablet-min {
margin-bottom: 30px;
margin-right: 30px;
}
}
&-title {
& .title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
@@ -387,34 +522,35 @@ export default {
font-size: 16px;
}
}
&-text {
& .text {
font-weight: 300;
font-size: 14px;
margin-top: 5px;
}
}
&__admin {
}
&__admin {
width: 100%;
padding: 20px;
order: 2;
@include tablet-min {
order: 3;
padding: 40px;
padding-top: 0px;
width: 100%;
padding: 20px;
order: 2;
@include tablet-min {
order: 3;
padding: 40px;
padding-top: 0px;
width: 100%;
}
&-title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
text-align: center;
font-size: 14px;
color: $green;
padding-bottom: 20px;
@include tablet-min {
font-size: 16px;
}
}
}
&-title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
text-align: center;
font-size: 14px;
color: $green;
padding-bottom: 20px;
@include tablet-min {
font-size: 16px;
}
}
}
}
</style>

View File

@@ -1,117 +1,249 @@
<template>
<li class="movies-item" :class="{'shortList': shortList}">
<a class="movies-item__link" :class="{'no-image': noImage}" @click.prevent="openMoviePopup(movie.id, movie.type)">
<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"
/>
<!-- TODO change to picture element -->
<figure class="movies-item__poster">
<img v-if="!noImage" class="movies-item__img" src="~assets/placeholder.png" v-img="poster()" alt="">
<img v-if="noImage" class="movies-item__img is-loaded" src="~assets/no-image.png" alt="">
</figure>
<div class="movies-item__content">
<p class="movies-item__title">{{ movie.title }}</p>
<p class="movies-item__title">{{ movie.year }}</p>
<div v-if="movie.download" class="progress">
<progress :value="movie.download.progress" max="100"></progress>
<span>{{ movie.download.state }}: {{ movie.download.progress }}%</span>
</div>
</a>
</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'
import img from "../directives/v-image";
export default {
props: ['movie', 'shortList'],
props: {
movie: {
type: Object,
required: true
},
shortList: {
type: Boolean,
required: false
}
},
directives: {
img: img
},
data(){
data() {
return {
noImage: false
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: {
// TODO handle missing images better and load diff sizes based on screen size
poster() {
if (this.movie.poster) {
return 'https://image.tmdb.org/t/p/w500' + this.movie.poster
} else {
this.noImage = true
}
},
openMoviePopup(id, type) {
this.$popup.open(id, type)
this.$popup.open(id, type);
}
}
}
};
</script>
<style lang="scss">
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/main";
.movies-item {
.movie-item {
padding: 10px;
width: 50%;
background-color: $background-color;
transition: background-color 0.5s ease;
@include tablet-min{
@include tablet-min {
padding: 15px;
width: 33%;
}
@include tablet-landscape-min{
@include tablet-landscape-min {
padding: 15px;
width: 25%;
}
@include desktop-min{
@include desktop-min {
padding: 15px;
width: 20%;
}
@include desktop-lg-min{
padding: 20px;
@include desktop-lg-min {
padding: 15px;
width: 12.5%;
}
&__link{
&: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);
}
}
}
&__content{
&__info {
padding-top: 15px;
}
&__poster{
transition: transform 0.5s ease, box-shadow 0.3s ease;
transform: translateZ(0);
}
&__img{
width: 100%;
opacity: 0;
transform: scale(0.97) translateZ(0);
transition: opacity 0.5s ease, transform 0.5s ease;
&.is-loaded{
opacity: 1;
transform: scale(1);
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;
}
}
}
&__link:not(.no-image):hover &__poster{
transform: scale(1.03);
box-shadow: 0 0 10px rgba($dark, 0.1);
}
.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;
}
&__title{
margin: 0;
font-size: 11px;
letter-spacing: 0.5px;
transition: color 0.5s ease;
cursor: pointer;
@include mobile-ls-min{
font-size: 12px;
}
@include tablet-min{
font-size: 14px;
}
}
&__link:hover &__title{
color: $text-color;
&: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,78 +1,93 @@
<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>
<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>
<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>
<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>
<li class="nav__item mobile-only"></li>
<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>
<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>
<div class="spacer"></div>
</div>
<router-link
class="nav__link nav__link--profile"
:to="{ name: 'profile' }"
v-if="userLoggedIn"
>
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use xlink:href="#iconLogin"></use>
</svg>
<span class="nav__link-title">Profile</span>
</div>
</router-link>
</li>
</ul>
</nav>
</template>
<script>
import storage from '@/storage'
import storage from "@/storage";
export default {
data(){
data() {
return {
listTypes: storage.homepageLists,
userLoggedIn: localStorage.getItem('token') ? true : false
}
userLoggedIn: localStorage.getItem("token") ? true : false
};
},
methods: {
setUserStatus(){
this.userLoggedIn = localStorage.getItem('token') ? true : false;
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');
toggleNav() {
document
.querySelector(".nav__hamburger")
.classList.toggle("nav__hamburger--active");
document
.querySelector(".nav__list")
.classList.toggle("nav__list--active");
}
},
created(){
created() {
// TODO move this to state manager
eventHub.$on('setUserStatus', this.setUserStatus);
eventHub.$on("setUserStatus", this.setUserStatus);
}
}
};
</script>
<style lang="scss" scoped>
@@ -83,45 +98,45 @@ export default {
width: 30px;
}
.spacer {
@include mobile-only {
width: 100%;
height: $header-size;
}
}
.nav {
transition: background .5s ease;
transition: background 0.5s ease;
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: 100%;
height: 50px;
height: var(--header-size);
z-index: 10;
display: block;
color: $text-color;
background-color: $background-color-secondary;
@include tablet-min{
@include tablet-min {
top: 0;
bottom: unset;
width: 95px;
height: 100vh;
}
&__logo {
width: 55px;
width: 95px;
height: $header-size;
display: flex;
align-items: center;
justify-content: center;
background: $background-nav-logo;
@include tablet-min{
width: 95px;
@include mobile-only {
align-items: flex-start;
padding-top: 0.5rem;
width: 55px;
}
&-image{
&-image {
width: 35px;
height: 31px;
fill: $green;
transition: transform 0.5s ease;
@include tablet-min{
@include tablet-min {
width: 45px;
height: 40px;
}
@@ -135,12 +150,12 @@ export default {
position: fixed;
width: 55px;
height: 50px;
top: 0;
bottom: 1.5rem;
right: 0;
cursor: pointer;
z-index: 10;
border-left: 1px solid $background-color;
@include tablet-min{
@include tablet-min {
display: none;
}
.bar {
@@ -172,9 +187,9 @@ export default {
}
}
&--active {
.bar{
.bar {
&:nth-child(1),
&:nth-child(3){
&:nth-child(3) {
width: 0;
}
&:nth-child(2) {
@@ -198,15 +213,21 @@ export default {
left: 0;
top: 50px;
border-top: 1px solid $background-color;
@include mobile-only {
display: flex;
position: absolute;
top: unset;
bottom: var(--header-size);
height: min-content;
flex-wrap: wrap;
font-size: 0;
opacity: 0;
visibility: hidden;
background-color: $background-95;
text-align: left;
&--active{
&--active {
opacity: 1;
visibility: visible;
}
@@ -221,15 +242,15 @@ export default {
}
}
&__item {
transition: background .5s ease, color .5s ease, border .5s ease;
transition: background 0.5s ease, color 0.5s ease, border 0.5s ease;
background-color: $background-color-secondary;
color: $text-color-70;
@include mobile-only {
flex: 0 0 50%;
flex: 0 0 33.3%;
text-align: center;
border-bottom: 1px solid $background-color;
&:nth-child(odd){
&:nth-child(odd) {
border-right: 1px solid $background-color;
&:last-child {
@@ -251,7 +272,8 @@ export default {
border-left: 1px solid $background-color;
}
}
&:hover, .is-active {
&:hover,
.is-active {
color: $text-color;
background-color: $background-color;
}
@@ -299,14 +321,14 @@ export default {
height: 20px;
margin-bottom: 5px;
}
}
&-title {
margin-top: 5px;
display: block;
width: 100%;
}
&:hover &-icon, &.is-active &-icon {
&:hover &-icon,
&.is-active &-icon {
fill: $text-color;
}
}

View File

@@ -2,10 +2,10 @@
<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>
<seasoned-button @click="toggleSettings">{{ showSettings ? 'hide settings' : 'show settings' }}</seasoned-button>
<seasoned-button @click="logOut">Log out</seasoned-button>
</div>
@@ -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,32 +57,21 @@ 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;
if (this.showSettings) {
this.$router.replace({ query: { settings: true} })
} else {
this.$router.replace({ name: 'profile' })
}
},
logOut(){
localStorage.clear();
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'home' });
this.$router.push('logout')
}
},
created(){
@@ -91,7 +79,8 @@ export default {
this.userLoggedIn = false;
} else {
this.userLoggedIn = true;
this.getUserInfo();
this.showSettings = window.location.toString().includes('settings=true')
getUserRequests()
.then(results => {

View File

@@ -2,23 +2,20 @@
<section>
<h1>Register new user</h1>
<seasoned-input placeholder="username" icon="Email" type="username" :value.sync="username" />
<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="requestNewUser"/>
<seasoned-input placeholder="repeat password" icon="Keyhole" type="password"
:value.sync="passwordRepeat" @enter="requestNewUser"/>
<seasoned-button @click="requestNewUser">Register</seasoned-button>
<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 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'
@@ -34,60 +31,47 @@ export default {
}
},
methods: {
requestNewUser(){
let { username, password, passwordRepeat } = this
submit() {
this.messages = [];
let { username, password, passwordRepeat } = this;
let verifyCredentials = this.checkCredentials(username, password, passwordRepeat);
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
}
if (verifyCredentials.verified) {
axios.post(`https://api.kevinmidboe.com/api/v1/user`, {
username: username,
password: password
})
.then(resp => {
let data = resp.data;
this.registerUser(username, password)
},
registerUser(username, password) {
register(username, password, true)
.then(data => {
if (data.success){
localStorage.setItem('token', data.token);
localStorage.setItem('username', username);
localStorage.setItem('admin', data.admin)
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 => {
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.response.data.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 })
}
});
}
else {
this.messages.push({ type: 'warning', title: 'Parse error', message: verifyCredentials.reason })
}
},
checkCredentials(username, password, passwordRepeat) {
if (!username || username.length === 0) {
return {
verified: false,
reason: 'Fill inn username'
}
}
else if (!password || !passwordRepeat) {
return {
verified: false,
reason: "Fill inn both password fields"
}
}
else if (password !== passwordRepeat) {
return {
verified: false,
reason: 'Passwords do not match'
}
}
else {
return {
verified: true,
reason: 'Verified credentials'
}
}
},
logOut(){
localStorage.clear();

View File

@@ -1,23 +1,41 @@
<template>
<div>
<div class="page-container">
<list-header :title="title" :info="resultCount" :sticky="true" />
<results-list :results="results" />
<div v-if="page < totalPages" class="fullwidth-button">
<seasoned-button @click="loadMore">load more</seasoned-button>
</div>
<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'
import ResultsList from '@/components/ResultsList'
import SeasonedButton from '@/components/ui/SeasonedButton'
import Loader from '@/components/ui/Loader'
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 },
@@ -37,60 +55,79 @@ export default {
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`
const loadedResults = this.results.length;
const totalResults = this.totalResults < 10000 ? this.totalResults : "∞";
return `${loadedResults} of ${totalResults} results`;
}
},
methods: {
search(query=this.query, page=this.page) {
searchTmdb(query, page)
.then(this.parseResponse)
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)
this.results.push(...data.results);
} else {
this.results = data.results
this.results = data.results;
}
this.totalPages = data.total_pages
this.totalResults = data.total_results || data.results.length
this.totalPages = data.total_pages;
this.totalResults = data.total_results || data.results.length;
this.loading = false
this.loading = false;
},
loadMore() {
this.page++
this.page++;
window.history.replaceState({}, 'search', `/#/search?query=${this.query}&page=${this.page}`)
this.search()
window.history.replaceState(
{},
"search",
`/#/search?query=${this.query}&page=${this.page}`
);
this.search();
}
},
created() {
const { query, page } = this.$route.query
const { query, page, adult, media_type } = this.$route.query;
if (!query) {
// abort
console.error('abort, no query')
console.error("abort, no query");
}
this.query = decodeURIComponent(query)
this.page = page ? page : 1
this.title = `Search results: ${this.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()
this.search();
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/media-queries";
@include mobile-only {
.page-container {
margin-top: 1rem;
}
}
.fullwidth-button {
width: 100%;
margin: 1rem 0;
@@ -98,5 +135,4 @@ export default {
display: flex;
justify-content: center;
}
</style>
</style>

View File

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

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,25 +2,29 @@
<section>
<h1>Sign in</h1>
<seasoned-input placeholder="username" icon="Email" type="username" :value.sync="username" />
<seasoned-input placeholder="password" icon="Keyhole" type="password" :value.sync="password" @enter="signin"/>
<seasoned-button @click="signin">sign in</seasoned-button>
<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>
<seasoned-messages :messages.sync="messages"></seasoned-messages>
</section>
</template>
<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 },
@@ -35,33 +39,44 @@ export default {
setValue(l, t) {
this[l] = t
},
signin(){
submit() {
this.messages = [];
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 })
}
});
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(){

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,7 +1,7 @@
<template>
<div class="seasoned-button">
<button type="button" class="button" @click="emit('click')" :class="{ active: active }"><slot></slot></button>
</div>
<button type="button" @click="emit('click')" :class="{ active: active }">
<slot></slot>
</button>
</template>
<script>
@@ -9,7 +9,11 @@
export default {
name: 'seasonedButton',
props: {
active: Boolean
active: {
type: Boolean,
default: false,
required: false
}
},
methods: {
emit() {
@@ -23,32 +27,39 @@ export default {
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.button{
button {
display: inline-block;
border: 1px solid $text-color;
text-transform: uppercase;
font-weight: 300;
font-size: 11px;
line-height: 2;
height: 45px;
font-weight: 300;
line-height: 1.5;
letter-spacing: 0.5px;
padding: 5px 20px 4px 20px;
text-transform: uppercase;
min-height: 45px;
padding: 5px 10px 4px 10px;
margin: 0;
margin-right: 0.3rem;
cursor: pointer;
color: $text-color;
background: $background-color-secondary;
cursor: pointer;
outline: none;
transition: background 0.5s ease, color 0.5s ease, border-color .5s ease;
@include tablet-min{
font-size: 12px;
@include desktop {
font-size: 0.8rem;
padding: 6px 20px 5px 20px;
}
body:not(.touch) &:hover, &:focus, &:active, &.active {
&:focus, &:active, &.active {
background: $text-color;
color: $background-color;
}
@media (hover: hover) {
&:hover {
background: $text-color;
color: $background-color;
}
}
}
</style>

View File

@@ -3,8 +3,8 @@
<div class="message" v-for="(message, index) in reversedMessages" :class="message.type || 'warning'" :key="index">
<span class="pinstripe"></span>
<div>
<h2>{{ message.title || defaultTitles[message.type] }}</h2>
<span>{{ 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>
@@ -41,14 +41,7 @@ export default {
const removedMessage = [...this.messages].filter(mes => mes !== e)
this.$emit('update:messages', removedMessage)
}
},
// watch: {
// messages(propState, oldState) {
// const newMessage = propState.filter(msg => !this.localMessages.includes(msg))
// console.log('newMessage', newMessage)
// this.localMessages = this.localMessages.concat(newMessage)
// }
// }
}
}
</script>
@@ -70,7 +63,6 @@ export default {
.message {
width: 100%;
max-width: 35rem;
height: 75px;
display: flex;
margin-top: 1rem;
@@ -78,12 +70,12 @@ export default {
color: $text-color-70;
> div {
margin: 6px 24px;
margin: 10px 24px;
width: 100%;
}
h2 {
.title {
font-weight: 300;
letter-spacing: 0.25px;
margin: 0;
@@ -91,10 +83,11 @@ export default {
color: $text-color;
transition: color .5s ease;
}
span {
.message {
font-weight: 300;
color: $text-color-70;
transition: color .5s ease;
margin: 0.2rem 0 0.5rem;
}
@include mobile-only {
@@ -112,9 +105,8 @@ export default {
}
.pinstripe {
height: 100%;
width: 0.5rem;
// background-color: $color-error-highlight;
background-color: $color-error-highlight;
}
.dismiss {

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

@@ -1,36 +1,38 @@
<template>
<div class="darkToggle">
<span @click="toggleDarkmode()">{{ darkmodeToggleIcon }}</span>
</div>
</template>
<script>
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'
document.body.className = this.darkmode ? "dark" : "light";
},
supported() {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle["colorScheme"] != null)
return computedStyle.colorScheme.includes("dark");
return false;
}
},
computed: {
darkmodeToggleIcon() {
return this.darkmode ? '🌝' : '🌚'
return this.darkmode ? "🌝" : "🌚";
}
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/media-queries";
.darkToggle {
height: 25px;
width: 25px;
@@ -41,11 +43,15 @@ export default {
margin-right: 2px;
bottom: 0;
right: 0;
z-index: 1;
z-index: 10;
@include mobile-only {
margin-bottom: 5rem;
}
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>
</style>

View File

@@ -1,16 +1,18 @@
<template>
<div>
<a @click="$emit('click')"><li>
<figure :class="activeClassIfActive">
<svg><use :xlink:href="iconRefNameIfActive"/></svg>
</figure>
<a @click="$emit('click')">
<li>
<figure v-if="iconRef" :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>
@@ -21,7 +23,7 @@ export default {
props: {
iconRef: {
type: String,
required: true
required: false
},
iconRefActive: {
type: String,
@@ -44,7 +46,7 @@ export default {
iconRefNameIfActive() {
const { iconRefActive, iconRef, active } = this
if ((iconRefActive && iconRef) & active) {
if ((iconRefActive && iconRef) && active) {
return iconRefActive
}
return iconRef
@@ -83,39 +85,53 @@ li {
border-bottom: 1px solid $text-color-5;
&:hover {
color: $text-color-70;
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, figure > svg {
width: 18px;
height: 18px;
margin: 0 7px 0 0;
fill: $text-color-50;
transition: fill 0.5s ease, transform 0.5s ease;
&.waiting {
transform: scale(0.8, 0.8);
}
&.pending {
fill: #f8bd2d;
}
&:hover &-icon {
fill: $text-color-70;
cursor: pointer;
}
&.active > svg {
fill: $green;
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;
}
}
}
}

View File

@@ -11,8 +11,8 @@ const setDocumentTitle = (state) => {
export default {
namespaced: true,
state: {
emoji: '🍕',
titlePrefix: 'request',
emoji: '',
titlePrefix: 'seasoned',
title: undefined
},
getters: {

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

@@ -0,0 +1,112 @@
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
},
isPlexAuthenticated: (state) => {
const settings = state.settings || getLocalStorageByKey('settings')
if (settings == null)
return false
const hasPlexId = settings['plex_userid']
return hasPlexId != null ? true : false
}
},
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,9 +11,16 @@ let routes = [
path: '/',
component: (resolve) => require(['./components/Home.vue'], resolve)
},
{
name: 'activity',
path: '/activity',
meta: { requiresAuth: true },
component: (resolve) => require(['./components/ActivityPage.vue'], resolve)
},
{
name: 'profile',
path: '/profile',
meta: { requiresAuth: true },
component: (resolve) => require(['./components/Profile.vue'], resolve)
},
{
@@ -41,11 +48,13 @@ let routes = [
{
name: 'settings',
path: '/settings',
meta: { requiresAuth: true },
component: (resolve) => require(['./components/Settings.vue'], resolve)
},
{
name: 'signin',
path: '/signin',
alias: '/login',
component: (resolve) => require(['./components/Signin.vue'], resolve)
},
// {
@@ -60,6 +69,17 @@ let routes = [
path: '/404',
component: (resolve) => require(['./components/404.vue'], resolve)
},
{
name: 'logout',
path: '/logout',
component: {
template: '<div></div>',
created() {
localStorage.clear();
this.$router.push({ name: 'home' });
}
}
},
{
path: '*',
redirect: '/'
@@ -71,7 +91,7 @@ let routes = [
];
const router = new VueRouter({
mode: 'hash',
mode: 'history',
base: '/',
routes,
linkActiveClass: 'is-active'
@@ -85,6 +105,13 @@ router.beforeEach((to, from, next) => {
document.querySelector('.nav__hamburger').classList.remove('nav__hamburger--active');
document.querySelector('.nav__list').classList.remove('nav__list--active');
}
if (to.matched.some(record => record.meta.requiresAuth)) {
if (localStorage.getItem('token') == null) {
next({ path: '/signin' });
}
}
next();
});

View File

@@ -1,11 +1,10 @@
.noselect {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently */
}
.end-section {
@@ -21,4 +20,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,17 +11,19 @@
--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-90: rgba(1, 210, 119, 0.9);
--color-green-70: rgba(1, 210, 119, 0.73);
--color-teal: #091c24;
--color-black: #081c24;
--white: #fff;
--white-70: rgba(255,255,255,0.7);
--white-70: rgba(255, 255, 255, 0.7);
--color-warning: rgba(241, 188, 53, 0.7);
--color-warning-highlight: #f1bc35;
@@ -29,7 +31,7 @@
--color-success-text: #fff;
--color-success-highlight: rgb(0, 100, 66);
--color-error: rgba(220, 48, 35, 0.8);
--color-error-highlight: #DC3023;
--color-error-highlight: #dc3023;
--header-size: 75px;
}
@@ -42,17 +44,18 @@
--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);
}
}
@include mobile-only {
:root {
--header-size: 50px;
--header-size: calc(50px + 1.5rem);
}
}
@@ -61,11 +64,12 @@ $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);
$black-80: rgba(0, 0, 0, 0.8);
$white: #fff;
$white-80: rgba(255,255,255,0.8);
$white-80: rgba(255, 255, 255, 0.8);
$text-color: var(--text-color) !default;
$text-color-70: var(--text-color-70) !default;
@@ -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 }

736
yarn.lock

File diff suppressed because it is too large Load Diff