165 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
a11ad2f651 Merge pull request #34 from KevinMidboe/fix/post-magnet-data
Added application json content type header
2019-10-31 19:18:36 +01:00
755bd116d5 Added application json content type headaer 2019-10-31 19:16:18 +01:00
9e33784781 Merge pull request #33 from KevinMidboe/fix/post-magnet-data
Data was sent as [object object], now we stringify the content first.
2019-10-31 18:42:04 +01:00
470bcdd72e Data was sent as [object object], now we stringify the content first. 2019-10-31 18:41:40 +01:00
d56a7d4dfe Merge pull request #32 from KevinMidboe/fix/mobile-seasoned-message-formatting
Better formatting for seasoned messages on mobile
2019-10-30 23:46:46 +01:00
b46e586c92 Resize the content for seasoned messages and the settings wrapper to look better on mobile 2019-10-30 23:45:48 +01:00
563eb3f1ef Merge pull request #31 from KevinMidboe/fix/restrictive-background-scroll
Disable scroll on content behind popover movie view
2019-10-30 22:12:44 +01:00
98644513ad When movie popup opens we add a no-scroll class to the body element. This prevents scrolling the content behind the popover content. 2019-10-30 22:11:09 +01:00
3033db02b8 Merge pull request #29 from KevinMidboe/fix/search-input-navigation-resets-cursor
Reset search-input cursor on upwards navigation
2019-10-30 21:57:05 +01:00
70a6ed189b When navigating up in the autocomplete search result list the cursor usually reset back to the start of the input. Now we get the element and use focus and setSelectionRange to move the cursor back to the end at the very next frame. 2019-10-30 21:55:39 +01:00
d7e4d2095c Merge pull request #2 from KevinMidboe/release/v2
Release/v1
2019-10-23 19:54:43 +02:00
1c0799a30a Also check the items source for name or title to find out if the response item came from movie or show index 2019-10-23 00:49:38 +02:00
2b3955060f Updated yarn lock 2019-10-23 00:46:10 +02:00
4ac4d642e7 Removed autogenerated docs 2019-10-23 00:43:17 +02:00
3d12cd2735 Added check svg icon for torrentList 2019-10-23 00:39:30 +02:00
4a44924f56 Updated webpack to resolve common file extensions 2019-10-23 00:38:36 +02:00
3910b5d7b2 Forgot to add getter defined for documentTitle store module 2019-10-23 00:33:32 +02:00
4a32fe5255 SeasonedInput can now be initialized with a value 2019-10-23 00:33:00 +02:00
8b9b2be891 Sortable class function for finding out which header should get a class showing that the column is selected 2019-10-23 00:32:42 +02:00
96321831d1 0 is computed as False, allow 0 just not undefined or null 2019-10-23 00:31:49 +02:00
39cd5ce04a Re-wrote most all api calls to use fetch over axios. There is still a problem with form authentication with plex. The response we get does not seem to be a json object. Updated what is expected to return from altered api methods in each component that uses them 2019-10-23 00:30:37 +02:00
4a46bbd2be Movie now uses the new documentTitle module to set when loading movie and when popover dismissed we set it to the previous documenTitle. Created a getter for documentTitle. This makes it easier to only get the title variable and not try save and parse the document title with all the extra prefixes 2019-10-22 23:34:54 +02:00
f45dcc560c Removed A LOT of the functionality in MoviesList and replaced it with the ResultsList component. Now loading of search results, lists (either directly by query or link) and users requests from profile are all separated out to their own page component; Search.vue, ListPage.vue and Profile.vue respectivly. With the change Home has been completly redone to use this new funcionality 2019-10-22 23:24:08 +02:00
1a014bea15 Added some fresh new todos 2019-10-22 23:19:24 +02:00
6d6f1ffd06 Updated seasonedinput to also handle two-way binded value prop. This changes is reflected most all places that seaoned-input is used
. Fuck, also added the new ResultsList which replaces MoviesList
2019-10-22 23:18:24 +02:00
4528b240e1 Popover now also removes its eventlistener on close 2019-10-22 23:08:03 +02:00
c454d9c9e0 Misc cleanup, more definitions of color with scss variables and added a lot of color transitions for when switching theme color. 2019-10-22 23:07:21 +02:00
f8c284cd71 Removed messages stylesheet 2019-10-22 22:58:37 +02:00
46daff2ddb Removed unused warning|error|success (s)css variables and added green-90; green color with 90% opacity 2019-10-22 22:58:04 +02:00
9bb98ce569 Removed unused script element and updated positioning of text to center, even on mobile. 2019-10-22 22:53:51 +02:00
001c243f95 New store module for setting the document title. Each route changes the document title to its name 2019-10-22 22:52:24 +02:00
0fdaf5bd4e Cleaned up 404 page. Removed elements and property set the height of background 2019-10-22 18:47:25 +02:00
931918c60b Movie title also gets a loading placeholder before we get a respons. (Loadingplaceholder are grey pulsing bars that indicate where content is going to load) 2019-10-21 19:50:13 +02:00
a9d3246b97 New route to settings directly 2019-10-21 00:25:29 +02:00
cde119592d Redid the template for profile, regist and siginin to better use the
components we have made and to use update function definition.
Changed the message system with SeasonedMessages. This means simpler
interaction and less duplicate code now that the messagesystem is a
separate component that both interface with.
2019-10-21 00:25:01 +02:00
031127fb1f Merge branch 'release/v2' of github.com:KevinMidboe/seasoned into release/v2 2019-10-21 00:15:59 +02:00
fa50dd3455 Finished dark mode! This means re-doing all sass variables in the
variables.scss file and defining css variables in :root and alterting
them based on prefered color scheme. This gives us a mechanism to set
custom color schemes for the entire site from one place and changing
between them just by setting a class to the body element. This is done
by overwriting the css variables and then our scss variables use these
changes and apply them downward. This seems like a really nice setup for
the switching between- and adding color schemes.
Also did a lot of cleanup of unused, duplicate or errors styling
throughout the application.
2019-10-21 00:13:21 +02:00
49c418c3f1 Toggle for manually setting dark or light mode 2019-10-20 23:19:19 +02:00
8e7aa77ee3 Removed normalize because we dont need any help 2019-10-20 23:16:25 +02:00
4b0fcca5d2 Number of torrent results is now dynamically fetched from store and sent as supplementaryText to sidebar component 2019-10-15 20:48:10 +02:00
585fa5afcf Added vuex module for setting if darkmode is supported in users browser 2019-10-05 18:02:16 +02:00
38cec8c31a Added vue-svg-inline-loader package and updated webpack config 2019-10-04 18:26:53 +02:00
431cb7c034 sortableSize helper function re-done to be a async function 2019-10-04 17:44:17 +02:00
91a92a30ad Renamed movie/sidebarAction.vue to ui/sidebarListElem.vue. Completly rewrote the component. Uses slots for text, way better semantic html elements used and logic is moved from the dom to computed functions. 2019-10-04 17:43:23 +02:00
9d819e9a14 Removed unused console statements 2019-10-04 16:11:29 +02:00
ca910089c5 Removed duplicate styling rules 2019-10-04 00:50:27 +02:00
6270206812 Reset webkit styling for our inputs 2019-10-04 00:40:45 +02:00
1d1a78608e Moved away inline css and added mobile rule to stretch the input wrapper closer to the edges of the screen 2019-10-04 00:36:36 +02:00
2e8795a317 Fixed bug where the toggle state was out of sync 2019-10-04 00:33:12 +02:00
f39560e041 Updated a stupid function name a longer name 2019-10-04 00:32:40 +02:00
6f74a5bff4 Input components now emit a "enter" event and our torrent input searches if "enter" event is received 2019-10-04 00:28:27 +02:00
c339045a0e When searching for torrents we can now edit the search query and search again 2019-10-04 00:22:07 +02:00
9e38b67857 Use our store value for number of torrents and implemented a getter in the sidebar action button component and action call from the torrentList component. 2019-10-04 00:21:18 +02:00
a6f72c8f6b Implemented store to allow torrentSearch to tell our sidebar action buttons how many results we got 2019-10-04 00:20:03 +02:00
c8f9cb7e22 Button gets a defualt height of 45px and more rules for setting input to 100% of parent 2019-10-04 00:17:31 +02:00
7bb624b942 Inputs now take up 100% of the div and the other div should device the size 2019-10-04 00:14:42 +02:00
b11d2f752b WIP. Collapsing backgroup header for move view on touchw 2019-08-14 00:22:27 +02:00
1a82b751ea Merge pull request #13 from KevinMidboe/snyk-fix-fb49d4d8d3aed8aec2f03b293aa793a6
[Snyk] Fix for 1 vulnerable dependencies
2019-07-27 12:38:38 +02:00
snyk-test
45bc0389ac fix: package.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-AXIOS-174505
2019-07-27 10:37:31 +00:00
67d3af0ed0 Emoji api now uses URL object to construct url path 2019-07-06 19:37:48 +02:00
74 changed files with 5751 additions and 6530 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

@@ -1,350 +0,0 @@
/*!
* AnchorJS - v4.0.0 - 2017-06-02
* https://github.com/bryanbraun/anchorjs
* Copyright (c) 2017 Bryan Braun; Licensed MIT
*/
/* eslint-env amd, node */
// https://github.com/umdjs/umd/blob/master/templates/returnExports.js
(function(root, factory) {
'use strict';
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define([], factory);
} else if (typeof module === 'object' && module.exports) {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory();
} else {
// Browser globals (root is window)
root.AnchorJS = factory();
root.anchors = new root.AnchorJS();
}
})(this, function() {
'use strict';
function AnchorJS(options) {
this.options = options || {};
this.elements = [];
/**
* Assigns options to the internal options object, and provides defaults.
* @param {Object} opts - Options object
*/
function _applyRemainingDefaultOptions(opts) {
opts.icon = opts.hasOwnProperty('icon') ? opts.icon : '\ue9cb'; // Accepts characters (and also URLs?), like '#', '¶', '❡', or '§'.
opts.visible = opts.hasOwnProperty('visible') ? opts.visible : 'hover'; // Also accepts 'always' & 'touch'
opts.placement = opts.hasOwnProperty('placement')
? opts.placement
: 'right'; // Also accepts 'left'
opts.class = opts.hasOwnProperty('class') ? opts.class : ''; // Accepts any class name.
// Using Math.floor here will ensure the value is Number-cast and an integer.
opts.truncate = opts.hasOwnProperty('truncate')
? Math.floor(opts.truncate)
: 64; // Accepts any value that can be typecast to a number.
}
_applyRemainingDefaultOptions(this.options);
/**
* Checks to see if this device supports touch. Uses criteria pulled from Modernizr:
* https://github.com/Modernizr/Modernizr/blob/da22eb27631fc4957f67607fe6042e85c0a84656/feature-detects/touchevents.js#L40
* @returns {Boolean} - true if the current device supports touch.
*/
this.isTouchDevice = function() {
return !!(
'ontouchstart' in window ||
(window.DocumentTouch && document instanceof DocumentTouch)
);
};
/**
* Add anchor links to page elements.
* @param {String|Array|Nodelist} selector - A CSS selector for targeting the elements you wish to add anchor links
* to. Also accepts an array or nodeList containing the relavant elements.
* @returns {this} - The AnchorJS object
*/
this.add = function(selector) {
var elements,
elsWithIds,
idList,
elementID,
i,
index,
count,
tidyText,
newTidyText,
readableID,
anchor,
visibleOptionToUse,
indexesToDrop = [];
// We reapply options here because somebody may have overwritten the default options object when setting options.
// For example, this overwrites all options but visible:
//
// anchors.options = { visible: 'always'; }
_applyRemainingDefaultOptions(this.options);
visibleOptionToUse = this.options.visible;
if (visibleOptionToUse === 'touch') {
visibleOptionToUse = this.isTouchDevice() ? 'always' : 'hover';
}
// Provide a sensible default selector, if none is given.
if (!selector) {
selector = 'h2, h3, h4, h5, h6';
}
elements = _getElements(selector);
if (elements.length === 0) {
return this;
}
_addBaselineStyles();
// We produce a list of existing IDs so we don't generate a duplicate.
elsWithIds = document.querySelectorAll('[id]');
idList = [].map.call(elsWithIds, function assign(el) {
return el.id;
});
for (i = 0; i < elements.length; i++) {
if (this.hasAnchorJSLink(elements[i])) {
indexesToDrop.push(i);
continue;
}
if (elements[i].hasAttribute('id')) {
elementID = elements[i].getAttribute('id');
} else if (elements[i].hasAttribute('data-anchor-id')) {
elementID = elements[i].getAttribute('data-anchor-id');
} else {
tidyText = this.urlify(elements[i].textContent);
// Compare our generated ID to existing IDs (and increment it if needed)
// before we add it to the page.
newTidyText = tidyText;
count = 0;
do {
if (index !== undefined) {
newTidyText = tidyText + '-' + count;
}
index = idList.indexOf(newTidyText);
count += 1;
} while (index !== -1);
index = undefined;
idList.push(newTidyText);
elements[i].setAttribute('id', newTidyText);
elementID = newTidyText;
}
readableID = elementID.replace(/-/g, ' ');
// The following code builds the following DOM structure in a more effiecient (albeit opaque) way.
// '<a class="anchorjs-link ' + this.options.class + '" href="#' + elementID + '" aria-label="Anchor link for: ' + readableID + '" data-anchorjs-icon="' + this.options.icon + '"></a>';
anchor = document.createElement('a');
anchor.className = 'anchorjs-link ' + this.options.class;
anchor.href = '#' + elementID;
anchor.setAttribute('aria-label', 'Anchor link for: ' + readableID);
anchor.setAttribute('data-anchorjs-icon', this.options.icon);
if (visibleOptionToUse === 'always') {
anchor.style.opacity = '1';
}
if (this.options.icon === '\ue9cb') {
anchor.style.font = '1em/1 anchorjs-icons';
// We set lineHeight = 1 here because the `anchorjs-icons` font family could otherwise affect the
// height of the heading. This isn't the case for icons with `placement: left`, so we restore
// line-height: inherit in that case, ensuring they remain positioned correctly. For more info,
// see https://github.com/bryanbraun/anchorjs/issues/39.
if (this.options.placement === 'left') {
anchor.style.lineHeight = 'inherit';
}
}
if (this.options.placement === 'left') {
anchor.style.position = 'absolute';
anchor.style.marginLeft = '-1em';
anchor.style.paddingRight = '0.5em';
elements[i].insertBefore(anchor, elements[i].firstChild);
} else {
// if the option provided is `right` (or anything else).
anchor.style.paddingLeft = '0.375em';
elements[i].appendChild(anchor);
}
}
for (i = 0; i < indexesToDrop.length; i++) {
elements.splice(indexesToDrop[i] - i, 1);
}
this.elements = this.elements.concat(elements);
return this;
};
/**
* Removes all anchorjs-links from elements targed by the selector.
* @param {String|Array|Nodelist} selector - A CSS selector string targeting elements with anchor links,
* OR a nodeList / array containing the DOM elements.
* @returns {this} - The AnchorJS object
*/
this.remove = function(selector) {
var index,
domAnchor,
elements = _getElements(selector);
for (var i = 0; i < elements.length; i++) {
domAnchor = elements[i].querySelector('.anchorjs-link');
if (domAnchor) {
// Drop the element from our main list, if it's in there.
index = this.elements.indexOf(elements[i]);
if (index !== -1) {
this.elements.splice(index, 1);
}
// Remove the anchor from the DOM.
elements[i].removeChild(domAnchor);
}
}
return this;
};
/**
* Removes all anchorjs links. Mostly used for tests.
*/
this.removeAll = function() {
this.remove(this.elements);
};
/**
* Urlify - Refine text so it makes a good ID.
*
* To do this, we remove apostrophes, replace nonsafe characters with hyphens,
* remove extra hyphens, truncate, trim hyphens, and make lowercase.
*
* @param {String} text - Any text. Usually pulled from the webpage element we are linking to.
* @returns {String} - hyphen-delimited text for use in IDs and URLs.
*/
this.urlify = function(text) {
// Regex for finding the nonsafe URL characters (many need escaping): & +$,:;=?@"#{}|^~[`%!'<>]./()*\
var nonsafeChars = /[& +$,:;=?@"#{}|^~[`%!'<>\]\.\/\(\)\*\\]/g,
urlText;
// The reason we include this _applyRemainingDefaultOptions is so urlify can be called independently,
// even after setting options. This can be useful for tests or other applications.
if (!this.options.truncate) {
_applyRemainingDefaultOptions(this.options);
}
// Note: we trim hyphens after truncating because truncating can cause dangling hyphens.
// Example string: // " ⚡⚡ Don't forget: URL fragments should be i18n-friendly, hyphenated, short, and clean."
urlText = text
.trim() // "⚡⚡ Don't forget: URL fragments should be i18n-friendly, hyphenated, short, and clean."
.replace(/\'/gi, '') // "⚡⚡ Dont forget: URL fragments should be i18n-friendly, hyphenated, short, and clean."
.replace(nonsafeChars, '-') // "⚡⚡-Dont-forget--URL-fragments-should-be-i18n-friendly--hyphenated--short--and-clean-"
.replace(/-{2,}/g, '-') // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated-short-and-clean-"
.substring(0, this.options.truncate) // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated-"
.replace(/^-+|-+$/gm, '') // "⚡⚡-Dont-forget-URL-fragments-should-be-i18n-friendly-hyphenated"
.toLowerCase(); // "⚡⚡-dont-forget-url-fragments-should-be-i18n-friendly-hyphenated"
return urlText;
};
/**
* Determines if this element already has an AnchorJS link on it.
* Uses this technique: http://stackoverflow.com/a/5898748/1154642
* @param {HTMLElemnt} el - a DOM node
* @returns {Boolean} true/false
*/
this.hasAnchorJSLink = function(el) {
var hasLeftAnchor =
el.firstChild &&
(' ' + el.firstChild.className + ' ').indexOf(' anchorjs-link ') > -1,
hasRightAnchor =
el.lastChild &&
(' ' + el.lastChild.className + ' ').indexOf(' anchorjs-link ') > -1;
return hasLeftAnchor || hasRightAnchor || false;
};
/**
* Turns a selector, nodeList, or array of elements into an array of elements (so we can use array methods).
* It also throws errors on any other inputs. Used to handle inputs to .add and .remove.
* @param {String|Array|Nodelist} input - A CSS selector string targeting elements with anchor links,
* OR a nodeList / array containing the DOM elements.
* @returns {Array} - An array containing the elements we want.
*/
function _getElements(input) {
var elements;
if (typeof input === 'string' || input instanceof String) {
// See https://davidwalsh.name/nodelist-array for the technique transforming nodeList -> Array.
elements = [].slice.call(document.querySelectorAll(input));
// I checked the 'input instanceof NodeList' test in IE9 and modern browsers and it worked for me.
} else if (Array.isArray(input) || input instanceof NodeList) {
elements = [].slice.call(input);
} else {
throw new Error('The selector provided to AnchorJS was invalid.');
}
return elements;
}
/**
* _addBaselineStyles
* Adds baseline styles to the page, used by all AnchorJS links irregardless of configuration.
*/
function _addBaselineStyles() {
// We don't want to add global baseline styles if they've been added before.
if (document.head.querySelector('style.anchorjs') !== null) {
return;
}
var style = document.createElement('style'),
linkRule =
' .anchorjs-link {' +
' opacity: 0;' +
' text-decoration: none;' +
' -webkit-font-smoothing: antialiased;' +
' -moz-osx-font-smoothing: grayscale;' +
' }',
hoverRule =
' *:hover > .anchorjs-link,' +
' .anchorjs-link:focus {' +
' opacity: 1;' +
' }',
anchorjsLinkFontFace =
' @font-face {' +
' font-family: "anchorjs-icons";' + // Icon from icomoon; 10px wide & 10px tall; 2 empty below & 4 above
' src: url(data:n/a;base64,AAEAAAALAIAAAwAwT1MvMg8yG2cAAAE4AAAAYGNtYXDp3gC3AAABpAAAAExnYXNwAAAAEAAAA9wAAAAIZ2x5ZlQCcfwAAAH4AAABCGhlYWQHFvHyAAAAvAAAADZoaGVhBnACFwAAAPQAAAAkaG10eASAADEAAAGYAAAADGxvY2EACACEAAAB8AAAAAhtYXhwAAYAVwAAARgAAAAgbmFtZQGOH9cAAAMAAAAAunBvc3QAAwAAAAADvAAAACAAAQAAAAEAAHzE2p9fDzz1AAkEAAAAAADRecUWAAAAANQA6R8AAAAAAoACwAAAAAgAAgAAAAAAAAABAAADwP/AAAACgAAA/9MCrQABAAAAAAAAAAAAAAAAAAAAAwABAAAAAwBVAAIAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAMCQAGQAAUAAAKZAswAAACPApkCzAAAAesAMwEJAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAAAAAAAAAAAAAQAAg//0DwP/AAEADwABAAAAAAQAAAAAAAAAAAAAAIAAAAAAAAAIAAAACgAAxAAAAAwAAAAMAAAAcAAEAAwAAABwAAwABAAAAHAAEADAAAAAIAAgAAgAAACDpy//9//8AAAAg6cv//f///+EWNwADAAEAAAAAAAAAAAAAAAAACACEAAEAAAAAAAAAAAAAAAAxAAACAAQARAKAAsAAKwBUAAABIiYnJjQ3NzY2MzIWFxYUBwcGIicmNDc3NjQnJiYjIgYHBwYUFxYUBwYGIwciJicmNDc3NjIXFhQHBwYUFxYWMzI2Nzc2NCcmNDc2MhcWFAcHBgYjARQGDAUtLXoWOR8fORYtLTgKGwoKCjgaGg0gEhIgDXoaGgkJBQwHdR85Fi0tOAobCgoKOBoaDSASEiANehoaCQkKGwotLXoWOR8BMwUFLYEuehYXFxYugC44CQkKGwo4GkoaDQ0NDXoaShoKGwoFBe8XFi6ALjgJCQobCjgaShoNDQ0NehpKGgobCgoKLYEuehYXAAAADACWAAEAAAAAAAEACAAAAAEAAAAAAAIAAwAIAAEAAAAAAAMACAAAAAEAAAAAAAQACAAAAAEAAAAAAAUAAQALAAEAAAAAAAYACAAAAAMAAQQJAAEAEAAMAAMAAQQJAAIABgAcAAMAAQQJAAMAEAAMAAMAAQQJAAQAEAAMAAMAAQQJAAUAAgAiAAMAAQQJAAYAEAAMYW5jaG9yanM0MDBAAGEAbgBjAGgAbwByAGoAcwA0ADAAMABAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAH//wAP) format("truetype");' +
' }',
pseudoElContent =
' [data-anchorjs-icon]::after {' +
' content: attr(data-anchorjs-icon);' +
' }',
firstStyleEl;
style.className = 'anchorjs';
style.appendChild(document.createTextNode('')); // Necessary for Webkit.
// We place it in the head with the other style tags, if possible, so as to
// not look out of place. We insert before the others so these styles can be
// overridden if necessary.
firstStyleEl = document.head.querySelector('[rel="stylesheet"], style');
if (firstStyleEl === undefined) {
document.head.appendChild(style);
} else {
document.head.insertBefore(style, firstStyleEl);
}
style.sheet.insertRule(linkRule, style.sheet.cssRules.length);
style.sheet.insertRule(hoverRule, style.sheet.cssRules.length);
style.sheet.insertRule(pseudoElContent, style.sheet.cssRules.length);
style.sheet.insertRule(anchorjsLinkFontFace, style.sheet.cssRules.length);
}
}
return AnchorJS;
});

View File

@@ -1,12 +0,0 @@
.input {
font-family: inherit;
display: block;
width: 100%;
height: 2rem;
padding: .5rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
font-size: .875rem;
border-radius: 3px;
box-sizing: border-box;
}

View File

@@ -1,544 +0,0 @@
/*! Basscss | http://basscss.com | MIT License */
.h1{ font-size: 2rem }
.h2{ font-size: 1.5rem }
.h3{ font-size: 1.25rem }
.h4{ font-size: 1rem }
.h5{ font-size: .875rem }
.h6{ font-size: .75rem }
.font-family-inherit{ font-family:inherit }
.font-size-inherit{ font-size:inherit }
.text-decoration-none{ text-decoration:none }
.bold{ font-weight: bold; font-weight: bold }
.regular{ font-weight:normal }
.italic{ font-style:italic }
.caps{ text-transform:uppercase; letter-spacing: .2em; }
.left-align{ text-align:left }
.center{ text-align:center }
.right-align{ text-align:right }
.justify{ text-align:justify }
.nowrap{ white-space:nowrap }
.break-word{ word-wrap:break-word }
.line-height-1{ line-height: 1 }
.line-height-2{ line-height: 1.125 }
.line-height-3{ line-height: 1.25 }
.line-height-4{ line-height: 1.5 }
.list-style-none{ list-style:none }
.underline{ text-decoration:underline }
.truncate{
max-width:100%;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.list-reset{
list-style:none;
padding-left:0;
}
.inline{ display:inline }
.block{ display:block }
.inline-block{ display:inline-block }
.table{ display:table }
.table-cell{ display:table-cell }
.overflow-hidden{ overflow:hidden }
.overflow-scroll{ overflow:scroll }
.overflow-auto{ overflow:auto }
.clearfix:before,
.clearfix:after{
content:" ";
display:table
}
.clearfix:after{ clear:both }
.left{ float:left }
.right{ float:right }
.fit{ max-width:100% }
.max-width-1{ max-width: 24rem }
.max-width-2{ max-width: 32rem }
.max-width-3{ max-width: 48rem }
.max-width-4{ max-width: 64rem }
.border-box{ box-sizing:border-box }
.align-baseline{ vertical-align:baseline }
.align-top{ vertical-align:top }
.align-middle{ vertical-align:middle }
.align-bottom{ vertical-align:bottom }
.m0{ margin:0 }
.mt0{ margin-top:0 }
.mr0{ margin-right:0 }
.mb0{ margin-bottom:0 }
.ml0{ margin-left:0 }
.mx0{ margin-left:0; margin-right:0 }
.my0{ margin-top:0; margin-bottom:0 }
.m1{ margin: .5rem }
.mt1{ margin-top: .5rem }
.mr1{ margin-right: .5rem }
.mb1{ margin-bottom: .5rem }
.ml1{ margin-left: .5rem }
.mx1{ margin-left: .5rem; margin-right: .5rem }
.my1{ margin-top: .5rem; margin-bottom: .5rem }
.m2{ margin: 1rem }
.mt2{ margin-top: 1rem }
.mr2{ margin-right: 1rem }
.mb2{ margin-bottom: 1rem }
.ml2{ margin-left: 1rem }
.mx2{ margin-left: 1rem; margin-right: 1rem }
.my2{ margin-top: 1rem; margin-bottom: 1rem }
.m3{ margin: 2rem }
.mt3{ margin-top: 2rem }
.mr3{ margin-right: 2rem }
.mb3{ margin-bottom: 2rem }
.ml3{ margin-left: 2rem }
.mx3{ margin-left: 2rem; margin-right: 2rem }
.my3{ margin-top: 2rem; margin-bottom: 2rem }
.m4{ margin: 4rem }
.mt4{ margin-top: 4rem }
.mr4{ margin-right: 4rem }
.mb4{ margin-bottom: 4rem }
.ml4{ margin-left: 4rem }
.mx4{ margin-left: 4rem; margin-right: 4rem }
.my4{ margin-top: 4rem; margin-bottom: 4rem }
.mxn1{ margin-left: -.5rem; margin-right: -.5rem; }
.mxn2{ margin-left: -1rem; margin-right: -1rem; }
.mxn3{ margin-left: -2rem; margin-right: -2rem; }
.mxn4{ margin-left: -4rem; margin-right: -4rem; }
.ml-auto{ margin-left:auto }
.mr-auto{ margin-right:auto }
.mx-auto{ margin-left:auto; margin-right:auto; }
.p0{ padding:0 }
.pt0{ padding-top:0 }
.pr0{ padding-right:0 }
.pb0{ padding-bottom:0 }
.pl0{ padding-left:0 }
.px0{ padding-left:0; padding-right:0 }
.py0{ padding-top:0; padding-bottom:0 }
.p1{ padding: .5rem }
.pt1{ padding-top: .5rem }
.pr1{ padding-right: .5rem }
.pb1{ padding-bottom: .5rem }
.pl1{ padding-left: .5rem }
.py1{ padding-top: .5rem; padding-bottom: .5rem }
.px1{ padding-left: .5rem; padding-right: .5rem }
.p2{ padding: 1rem }
.pt2{ padding-top: 1rem }
.pr2{ padding-right: 1rem }
.pb2{ padding-bottom: 1rem }
.pl2{ padding-left: 1rem }
.py2{ padding-top: 1rem; padding-bottom: 1rem }
.px2{ padding-left: 1rem; padding-right: 1rem }
.p3{ padding: 2rem }
.pt3{ padding-top: 2rem }
.pr3{ padding-right: 2rem }
.pb3{ padding-bottom: 2rem }
.pl3{ padding-left: 2rem }
.py3{ padding-top: 2rem; padding-bottom: 2rem }
.px3{ padding-left: 2rem; padding-right: 2rem }
.p4{ padding: 4rem }
.pt4{ padding-top: 4rem }
.pr4{ padding-right: 4rem }
.pb4{ padding-bottom: 4rem }
.pl4{ padding-left: 4rem }
.py4{ padding-top: 4rem; padding-bottom: 4rem }
.px4{ padding-left: 4rem; padding-right: 4rem }
.col{
float:left;
box-sizing:border-box;
}
.col-right{
float:right;
box-sizing:border-box;
}
.col-1{
width:8.33333%;
}
.col-2{
width:16.66667%;
}
.col-3{
width:25%;
}
.col-4{
width:33.33333%;
}
.col-5{
width:41.66667%;
}
.col-6{
width:50%;
}
.col-7{
width:58.33333%;
}
.col-8{
width:66.66667%;
}
.col-9{
width:75%;
}
.col-10{
width:83.33333%;
}
.col-11{
width:91.66667%;
}
.col-12{
width:100%;
}
@media (min-width: 40em){
.sm-col{
float:left;
box-sizing:border-box;
}
.sm-col-right{
float:right;
box-sizing:border-box;
}
.sm-col-1{
width:8.33333%;
}
.sm-col-2{
width:16.66667%;
}
.sm-col-3{
width:25%;
}
.sm-col-4{
width:33.33333%;
}
.sm-col-5{
width:41.66667%;
}
.sm-col-6{
width:50%;
}
.sm-col-7{
width:58.33333%;
}
.sm-col-8{
width:66.66667%;
}
.sm-col-9{
width:75%;
}
.sm-col-10{
width:83.33333%;
}
.sm-col-11{
width:91.66667%;
}
.sm-col-12{
width:100%;
}
}
@media (min-width: 52em){
.md-col{
float:left;
box-sizing:border-box;
}
.md-col-right{
float:right;
box-sizing:border-box;
}
.md-col-1{
width:8.33333%;
}
.md-col-2{
width:16.66667%;
}
.md-col-3{
width:25%;
}
.md-col-4{
width:33.33333%;
}
.md-col-5{
width:41.66667%;
}
.md-col-6{
width:50%;
}
.md-col-7{
width:58.33333%;
}
.md-col-8{
width:66.66667%;
}
.md-col-9{
width:75%;
}
.md-col-10{
width:83.33333%;
}
.md-col-11{
width:91.66667%;
}
.md-col-12{
width:100%;
}
}
@media (min-width: 64em){
.lg-col{
float:left;
box-sizing:border-box;
}
.lg-col-right{
float:right;
box-sizing:border-box;
}
.lg-col-1{
width:8.33333%;
}
.lg-col-2{
width:16.66667%;
}
.lg-col-3{
width:25%;
}
.lg-col-4{
width:33.33333%;
}
.lg-col-5{
width:41.66667%;
}
.lg-col-6{
width:50%;
}
.lg-col-7{
width:58.33333%;
}
.lg-col-8{
width:66.66667%;
}
.lg-col-9{
width:75%;
}
.lg-col-10{
width:83.33333%;
}
.lg-col-11{
width:91.66667%;
}
.lg-col-12{
width:100%;
}
}
.flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex }
@media (min-width: 40em){
.sm-flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex }
}
@media (min-width: 52em){
.md-flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex }
}
@media (min-width: 64em){
.lg-flex{ display:-webkit-box; display:-webkit-flex; display:-ms-flexbox; display:flex }
}
.flex-column{ -webkit-box-orient:vertical; -webkit-box-direction:normal; -webkit-flex-direction:column; -ms-flex-direction:column; flex-direction:column }
.flex-wrap{ -webkit-flex-wrap:wrap; -ms-flex-wrap:wrap; flex-wrap:wrap }
.items-start{ -webkit-box-align:start; -webkit-align-items:flex-start; -ms-flex-align:start; -ms-grid-row-align:flex-start; align-items:flex-start }
.items-end{ -webkit-box-align:end; -webkit-align-items:flex-end; -ms-flex-align:end; -ms-grid-row-align:flex-end; align-items:flex-end }
.items-center{ -webkit-box-align:center; -webkit-align-items:center; -ms-flex-align:center; -ms-grid-row-align:center; align-items:center }
.items-baseline{ -webkit-box-align:baseline; -webkit-align-items:baseline; -ms-flex-align:baseline; -ms-grid-row-align:baseline; align-items:baseline }
.items-stretch{ -webkit-box-align:stretch; -webkit-align-items:stretch; -ms-flex-align:stretch; -ms-grid-row-align:stretch; align-items:stretch }
.self-start{ -webkit-align-self:flex-start; -ms-flex-item-align:start; align-self:flex-start }
.self-end{ -webkit-align-self:flex-end; -ms-flex-item-align:end; align-self:flex-end }
.self-center{ -webkit-align-self:center; -ms-flex-item-align:center; align-self:center }
.self-baseline{ -webkit-align-self:baseline; -ms-flex-item-align:baseline; align-self:baseline }
.self-stretch{ -webkit-align-self:stretch; -ms-flex-item-align:stretch; align-self:stretch }
.justify-start{ -webkit-box-pack:start; -webkit-justify-content:flex-start; -ms-flex-pack:start; justify-content:flex-start }
.justify-end{ -webkit-box-pack:end; -webkit-justify-content:flex-end; -ms-flex-pack:end; justify-content:flex-end }
.justify-center{ -webkit-box-pack:center; -webkit-justify-content:center; -ms-flex-pack:center; justify-content:center }
.justify-between{ -webkit-box-pack:justify; -webkit-justify-content:space-between; -ms-flex-pack:justify; justify-content:space-between }
.justify-around{ -webkit-justify-content:space-around; -ms-flex-pack:distribute; justify-content:space-around }
.content-start{ -webkit-align-content:flex-start; -ms-flex-line-pack:start; align-content:flex-start }
.content-end{ -webkit-align-content:flex-end; -ms-flex-line-pack:end; align-content:flex-end }
.content-center{ -webkit-align-content:center; -ms-flex-line-pack:center; align-content:center }
.content-between{ -webkit-align-content:space-between; -ms-flex-line-pack:justify; align-content:space-between }
.content-around{ -webkit-align-content:space-around; -ms-flex-line-pack:distribute; align-content:space-around }
.content-stretch{ -webkit-align-content:stretch; -ms-flex-line-pack:stretch; align-content:stretch }
.flex-auto{
-webkit-box-flex:1;
-webkit-flex:1 1 auto;
-ms-flex:1 1 auto;
flex:1 1 auto;
min-width:0;
min-height:0;
}
.flex-none{ -webkit-box-flex:0; -webkit-flex:none; -ms-flex:none; flex:none }
.fs0{ flex-shrink: 0 }
.order-0{ -webkit-box-ordinal-group:1; -webkit-order:0; -ms-flex-order:0; order:0 }
.order-1{ -webkit-box-ordinal-group:2; -webkit-order:1; -ms-flex-order:1; order:1 }
.order-2{ -webkit-box-ordinal-group:3; -webkit-order:2; -ms-flex-order:2; order:2 }
.order-3{ -webkit-box-ordinal-group:4; -webkit-order:3; -ms-flex-order:3; order:3 }
.order-last{ -webkit-box-ordinal-group:100000; -webkit-order:99999; -ms-flex-order:99999; order:99999 }
.relative{ position:relative }
.absolute{ position:absolute }
.fixed{ position:fixed }
.top-0{ top:0 }
.right-0{ right:0 }
.bottom-0{ bottom:0 }
.left-0{ left:0 }
.z1{ z-index: 1 }
.z2{ z-index: 2 }
.z3{ z-index: 3 }
.z4{ z-index: 4 }
.border{
border-style:solid;
border-width: 1px;
}
.border-top{
border-top-style:solid;
border-top-width: 1px;
}
.border-right{
border-right-style:solid;
border-right-width: 1px;
}
.border-bottom{
border-bottom-style:solid;
border-bottom-width: 1px;
}
.border-left{
border-left-style:solid;
border-left-width: 1px;
}
.border-none{ border:0 }
.rounded{ border-radius: 3px }
.circle{ border-radius:50% }
.rounded-top{ border-radius: 3px 3px 0 0 }
.rounded-right{ border-radius: 0 3px 3px 0 }
.rounded-bottom{ border-radius: 0 0 3px 3px }
.rounded-left{ border-radius: 3px 0 0 3px }
.not-rounded{ border-radius:0 }
.hide{
position:absolute !important;
height:1px;
width:1px;
overflow:hidden;
clip:rect(1px, 1px, 1px, 1px);
}
@media (max-width: 40em){
.xs-hide{ display:none !important }
}
@media (min-width: 40em) and (max-width: 52em){
.sm-hide{ display:none !important }
}
@media (min-width: 52em) and (max-width: 64em){
.md-hide{ display:none !important }
}
@media (min-width: 64em){
.lg-hide{ display:none !important }
}
.display-none{ display:none !important }

View File

@@ -1,93 +0,0 @@
Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,23 +0,0 @@
@font-face{
font-family: 'Source Code Pro';
font-weight: 400;
font-style: normal;
font-stretch: normal;
src: url('EOT/SourceCodePro-Regular.eot') format('embedded-opentype'),
url('WOFF2/TTF/SourceCodePro-Regular.ttf.woff2') format('woff2'),
url('WOFF/OTF/SourceCodePro-Regular.otf.woff') format('woff'),
url('OTF/SourceCodePro-Regular.otf') format('opentype'),
url('TTF/SourceCodePro-Regular.ttf') format('truetype');
}
@font-face{
font-family: 'Source Code Pro';
font-weight: 700;
font-style: normal;
font-stretch: normal;
src: url('EOT/SourceCodePro-Bold.eot') format('embedded-opentype'),
url('WOFF2/TTF/SourceCodePro-Bold.ttf.woff2') format('woff2'),
url('WOFF/OTF/SourceCodePro-Bold.otf.woff') format('woff'),
url('OTF/SourceCodePro-Bold.otf') format('opentype'),
url('TTF/SourceCodePro-Bold.ttf') format('truetype');
}

View File

@@ -1,123 +0,0 @@
/*
github.com style (c) Vasily Polovnyov <vast@whiteants.net>
*/
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
color: #333;
background: #f8f8f8;
-webkit-text-size-adjust: none;
}
.hljs-comment,
.diff .hljs-header,
.hljs-javadoc {
color: #998;
font-style: italic;
}
.hljs-keyword,
.css .rule .hljs-keyword,
.hljs-winutils,
.nginx .hljs-title,
.hljs-subst,
.hljs-request,
.hljs-status {
color: #1184CE;
}
.hljs-number,
.hljs-hexcolor,
.ruby .hljs-constant {
color: #ed225d;
}
.hljs-string,
.hljs-tag .hljs-value,
.hljs-phpdoc,
.hljs-dartdoc,
.tex .hljs-formula {
color: #ed225d;
}
.hljs-title,
.hljs-id,
.scss .hljs-preprocessor {
color: #900;
font-weight: bold;
}
.hljs-list .hljs-keyword,
.hljs-subst {
font-weight: normal;
}
.hljs-class .hljs-title,
.hljs-type,
.vhdl .hljs-literal,
.tex .hljs-command {
color: #458;
font-weight: bold;
}
.hljs-tag,
.hljs-tag .hljs-title,
.hljs-rules .hljs-property,
.django .hljs-tag .hljs-keyword {
color: #000080;
font-weight: normal;
}
.hljs-attribute,
.hljs-variable,
.lisp .hljs-body {
color: #008080;
}
.hljs-regexp {
color: #009926;
}
.hljs-symbol,
.ruby .hljs-symbol .hljs-string,
.lisp .hljs-keyword,
.clojure .hljs-keyword,
.scheme .hljs-keyword,
.tex .hljs-special,
.hljs-prompt {
color: #990073;
}
.hljs-built_in {
color: #0086b3;
}
.hljs-preprocessor,
.hljs-pragma,
.hljs-pi,
.hljs-doctype,
.hljs-shebang,
.hljs-cdata {
color: #999;
font-weight: bold;
}
.hljs-deletion {
background: #fdd;
}
.hljs-addition {
background: #dfd;
}
.diff .hljs-change {
background: #0086b3;
}
.hljs-chunk {
color: #aaa;
}

View File

@@ -1,168 +0,0 @@
/* global anchors */
// add anchor links to headers
anchors.options.placement = 'left';
anchors.add('h3');
// Filter UI
var tocElements = document.getElementById('toc').getElementsByTagName('li');
document.getElementById('filter-input').addEventListener('keyup', function(e) {
var i, element, children;
// enter key
if (e.keyCode === 13) {
// go to the first displayed item in the toc
for (i = 0; i < tocElements.length; i++) {
element = tocElements[i];
if (!element.classList.contains('display-none')) {
location.replace(element.firstChild.href);
return e.preventDefault();
}
}
}
var match = function() {
return true;
};
var value = this.value.toLowerCase();
if (!value.match(/^\s*$/)) {
match = function(element) {
var html = element.firstChild.innerHTML;
return html && html.toLowerCase().indexOf(value) !== -1;
};
}
for (i = 0; i < tocElements.length; i++) {
element = tocElements[i];
children = Array.from(element.getElementsByTagName('li'));
if (match(element) || children.some(match)) {
element.classList.remove('display-none');
} else {
element.classList.add('display-none');
}
}
});
var items = document.getElementsByClassName('toggle-sibling');
for (var j = 0; j < items.length; j++) {
items[j].addEventListener('click', toggleSibling);
}
function toggleSibling() {
var stepSibling = this.parentNode.getElementsByClassName('toggle-target')[0];
var icon = this.getElementsByClassName('icon')[0];
var klass = 'display-none';
if (stepSibling.classList.contains(klass)) {
stepSibling.classList.remove(klass);
icon.innerHTML = '▾';
} else {
stepSibling.classList.add(klass);
icon.innerHTML = '▸';
}
}
function showHashTarget(targetId) {
if (targetId) {
var hashTarget = document.getElementById(targetId);
// new target is hidden
if (
hashTarget &&
hashTarget.offsetHeight === 0 &&
hashTarget.parentNode.parentNode.classList.contains('display-none')
) {
hashTarget.parentNode.parentNode.classList.remove('display-none');
}
}
}
function scrollIntoView(targetId) {
// Only scroll to element if we don't have a stored scroll position.
if (targetId && !history.state) {
var hashTarget = document.getElementById(targetId);
if (hashTarget) {
hashTarget.scrollIntoView();
}
}
}
function gotoCurrentTarget() {
showHashTarget(location.hash.substring(1));
scrollIntoView(location.hash.substring(1));
}
window.addEventListener('hashchange', gotoCurrentTarget);
gotoCurrentTarget();
var toclinks = document.getElementsByClassName('pre-open');
for (var k = 0; k < toclinks.length; k++) {
toclinks[k].addEventListener('mousedown', preOpen, false);
}
function preOpen() {
showHashTarget(this.hash.substring(1));
}
var split_left = document.querySelector('#split-left');
var split_right = document.querySelector('#split-right');
var split_parent = split_left.parentNode;
var cw_with_sb = split_left.clientWidth;
split_left.style.overflow = 'hidden';
var cw_without_sb = split_left.clientWidth;
split_left.style.overflow = '';
Split(['#split-left', '#split-right'], {
elementStyle: function(dimension, size, gutterSize) {
return {
'flex-basis': 'calc(' + size + '% - ' + gutterSize + 'px)'
};
},
gutterStyle: function(dimension, gutterSize) {
return {
'flex-basis': gutterSize + 'px'
};
},
gutterSize: 20,
sizes: [33, 67]
});
// Chrome doesn't remember scroll position properly so do it ourselves.
// Also works on Firefox and Edge.
function updateState() {
history.replaceState(
{
left_top: split_left.scrollTop,
right_top: split_right.scrollTop
},
document.title
);
}
function loadState(ev) {
if (ev) {
// Edge doesn't replace change history.state on popstate.
history.replaceState(ev.state, document.title);
}
if (history.state) {
split_left.scrollTop = history.state.left_top;
split_right.scrollTop = history.state.right_top;
}
}
window.addEventListener('load', function() {
// Restore after Firefox scrolls to hash.
setTimeout(function() {
loadState();
// Update with initial scroll position.
updateState();
// Update scroll positions only after we've loaded because Firefox
// emits an initial scroll event with 0.
split_left.addEventListener('scroll', updateState);
split_right.addEventListener('scroll', updateState);
}, 1);
});
window.addEventListener('popstate', loadState);

View File

@@ -1,15 +0,0 @@
.gutter {
background-color: #f5f5f5;
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-vertical {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=');
cursor: ns-resize;
}
.gutter.gutter-horizontal {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
cursor: ew-resize;
}

View File

@@ -1,586 +0,0 @@
/*! Split.js - v1.3.5 */
// https://github.com/nathancahill/Split.js
// Copyright (c) 2017 Nathan Cahill; Licensed MIT
(function(global, factory) {
typeof exports === 'object' && typeof module !== 'undefined'
? (module.exports = factory())
: typeof define === 'function' && define.amd
? define(factory)
: (global.Split = factory());
})(this, function() {
'use strict';
// The programming goals of Split.js are to deliver readable, understandable and
// maintainable code, while at the same time manually optimizing for tiny minified file size,
// browser compatibility without additional requirements, graceful fallback (IE8 is supported)
// and very few assumptions about the user's page layout.
var global = window;
var document = global.document;
// Save a couple long function names that are used frequently.
// This optimization saves around 400 bytes.
var addEventListener = 'addEventListener';
var removeEventListener = 'removeEventListener';
var getBoundingClientRect = 'getBoundingClientRect';
var NOOP = function() {
return false;
};
// Figure out if we're in IE8 or not. IE8 will still render correctly,
// but will be static instead of draggable.
var isIE8 = global.attachEvent && !global[addEventListener];
// This library only needs two helper functions:
//
// The first determines which prefixes of CSS calc we need.
// We only need to do this once on startup, when this anonymous function is called.
//
// Tests -webkit, -moz and -o prefixes. Modified from StackOverflow:
// http://stackoverflow.com/questions/16625140/js-feature-detection-to-detect-the-usage-of-webkit-calc-over-calc/16625167#16625167
var calc =
['', '-webkit-', '-moz-', '-o-']
.filter(function(prefix) {
var el = document.createElement('div');
el.style.cssText = 'width:' + prefix + 'calc(9px)';
return !!el.style.length;
})
.shift() + 'calc';
// The second helper function allows elements and string selectors to be used
// interchangeably. In either case an element is returned. This allows us to
// do `Split([elem1, elem2])` as well as `Split(['#id1', '#id2'])`.
var elementOrSelector = function(el) {
if (typeof el === 'string' || el instanceof String) {
return document.querySelector(el);
}
return el;
};
// The main function to initialize a split. Split.js thinks about each pair
// of elements as an independant pair. Dragging the gutter between two elements
// only changes the dimensions of elements in that pair. This is key to understanding
// how the following functions operate, since each function is bound to a pair.
//
// A pair object is shaped like this:
//
// {
// a: DOM element,
// b: DOM element,
// aMin: Number,
// bMin: Number,
// dragging: Boolean,
// parent: DOM element,
// isFirst: Boolean,
// isLast: Boolean,
// direction: 'horizontal' | 'vertical'
// }
//
// The basic sequence:
//
// 1. Set defaults to something sane. `options` doesn't have to be passed at all.
// 2. Initialize a bunch of strings based on the direction we're splitting.
// A lot of the behavior in the rest of the library is paramatized down to
// rely on CSS strings and classes.
// 3. Define the dragging helper functions, and a few helpers to go with them.
// 4. Loop through the elements while pairing them off. Every pair gets an
// `pair` object, a gutter, and special isFirst/isLast properties.
// 5. Actually size the pair elements, insert gutters and attach event listeners.
var Split = function(ids, options) {
if (options === void 0) options = {};
var dimension;
var clientDimension;
var clientAxis;
var position;
var paddingA;
var paddingB;
var elements;
// All DOM elements in the split should have a common parent. We can grab
// the first elements parent and hope users read the docs because the
// behavior will be whacky otherwise.
var parent = elementOrSelector(ids[0]).parentNode;
var parentFlexDirection = global.getComputedStyle(parent).flexDirection;
// Set default options.sizes to equal percentages of the parent element.
var sizes =
options.sizes ||
ids.map(function() {
return 100 / ids.length;
});
// Standardize minSize to an array if it isn't already. This allows minSize
// to be passed as a number.
var minSize = options.minSize !== undefined ? options.minSize : 100;
var minSizes = Array.isArray(minSize)
? minSize
: ids.map(function() {
return minSize;
});
var gutterSize = options.gutterSize !== undefined ? options.gutterSize : 10;
var snapOffset = options.snapOffset !== undefined ? options.snapOffset : 30;
var direction = options.direction || 'horizontal';
var cursor =
options.cursor ||
(direction === 'horizontal' ? 'ew-resize' : 'ns-resize');
var gutter =
options.gutter ||
function(i, gutterDirection) {
var gut = document.createElement('div');
gut.className = 'gutter gutter-' + gutterDirection;
return gut;
};
var elementStyle =
options.elementStyle ||
function(dim, size, gutSize) {
var style = {};
if (typeof size !== 'string' && !(size instanceof String)) {
if (!isIE8) {
style[dim] = calc + '(' + size + '% - ' + gutSize + 'px)';
} else {
style[dim] = size + '%';
}
} else {
style[dim] = size;
}
return style;
};
var gutterStyle =
options.gutterStyle ||
function(dim, gutSize) {
return (obj = {}), (obj[dim] = gutSize + 'px'), obj;
var obj;
};
// 2. Initialize a bunch of strings based on the direction we're splitting.
// A lot of the behavior in the rest of the library is paramatized down to
// rely on CSS strings and classes.
if (direction === 'horizontal') {
dimension = 'width';
clientDimension = 'clientWidth';
clientAxis = 'clientX';
position = 'left';
paddingA = 'paddingLeft';
paddingB = 'paddingRight';
} else if (direction === 'vertical') {
dimension = 'height';
clientDimension = 'clientHeight';
clientAxis = 'clientY';
position = 'top';
paddingA = 'paddingTop';
paddingB = 'paddingBottom';
}
// 3. Define the dragging helper functions, and a few helpers to go with them.
// Each helper is bound to a pair object that contains it's metadata. This
// also makes it easy to store references to listeners that that will be
// added and removed.
//
// Even though there are no other functions contained in them, aliasing
// this to self saves 50 bytes or so since it's used so frequently.
//
// The pair object saves metadata like dragging state, position and
// event listener references.
function setElementSize(el, size, gutSize) {
// Split.js allows setting sizes via numbers (ideally), or if you must,
// by string, like '300px'. This is less than ideal, because it breaks
// the fluid layout that `calc(% - px)` provides. You're on your own if you do that,
// make sure you calculate the gutter size by hand.
var style = elementStyle(dimension, size, gutSize);
// eslint-disable-next-line no-param-reassign
Object.keys(style).forEach(function(prop) {
return (el.style[prop] = style[prop]);
});
}
function setGutterSize(gutterElement, gutSize) {
var style = gutterStyle(dimension, gutSize);
// eslint-disable-next-line no-param-reassign
Object.keys(style).forEach(function(prop) {
return (gutterElement.style[prop] = style[prop]);
});
}
// Actually adjust the size of elements `a` and `b` to `offset` while dragging.
// calc is used to allow calc(percentage + gutterpx) on the whole split instance,
// which allows the viewport to be resized without additional logic.
// Element a's size is the same as offset. b's size is total size - a size.
// Both sizes are calculated from the initial parent percentage,
// then the gutter size is subtracted.
function adjust(offset) {
var a = elements[this.a];
var b = elements[this.b];
var percentage = a.size + b.size;
a.size = (offset / this.size) * percentage;
b.size = percentage - (offset / this.size) * percentage;
setElementSize(a.element, a.size, this.aGutterSize);
setElementSize(b.element, b.size, this.bGutterSize);
}
// drag, where all the magic happens. The logic is really quite simple:
//
// 1. Ignore if the pair is not dragging.
// 2. Get the offset of the event.
// 3. Snap offset to min if within snappable range (within min + snapOffset).
// 4. Actually adjust each element in the pair to offset.
//
// ---------------------------------------------------------------------
// | | <- a.minSize || b.minSize -> | |
// | | | <- this.snapOffset || this.snapOffset -> | | |
// | | | || | | |
// | | | || | | |
// ---------------------------------------------------------------------
// | <- this.start this.size -> |
function drag(e) {
var offset;
if (!this.dragging) {
return;
}
// Get the offset of the event from the first side of the
// pair `this.start`. Supports touch events, but not multitouch, so only the first
// finger `touches[0]` is counted.
if ('touches' in e) {
offset = e.touches[0][clientAxis] - this.start;
} else {
offset = e[clientAxis] - this.start;
}
// If within snapOffset of min or max, set offset to min or max.
// snapOffset buffers a.minSize and b.minSize, so logic is opposite for both.
// Include the appropriate gutter sizes to prevent overflows.
if (offset <= elements[this.a].minSize + snapOffset + this.aGutterSize) {
offset = elements[this.a].minSize + this.aGutterSize;
} else if (
offset >=
this.size - (elements[this.b].minSize + snapOffset + this.bGutterSize)
) {
offset = this.size - (elements[this.b].minSize + this.bGutterSize);
}
// Actually adjust the size.
adjust.call(this, offset);
// Call the drag callback continously. Don't do anything too intensive
// in this callback.
if (options.onDrag) {
options.onDrag();
}
}
// Cache some important sizes when drag starts, so we don't have to do that
// continously:
//
// `size`: The total size of the pair. First + second + first gutter + second gutter.
// `start`: The leading side of the first element.
//
// ------------------------------------------------
// | aGutterSize -> ||| |
// | ||| |
// | ||| |
// | ||| <- bGutterSize |
// ------------------------------------------------
// | <- start size -> |
function calculateSizes() {
// Figure out the parent size minus padding.
var a = elements[this.a].element;
var b = elements[this.b].element;
this.size =
a[getBoundingClientRect]()[dimension] +
b[getBoundingClientRect]()[dimension] +
this.aGutterSize +
this.bGutterSize;
this.start = a[getBoundingClientRect]()[position];
}
// stopDragging is very similar to startDragging in reverse.
function stopDragging() {
var self = this;
var a = elements[self.a].element;
var b = elements[self.b].element;
if (self.dragging && options.onDragEnd) {
options.onDragEnd();
}
self.dragging = false;
// Remove the stored event listeners. This is why we store them.
global[removeEventListener]('mouseup', self.stop);
global[removeEventListener]('touchend', self.stop);
global[removeEventListener]('touchcancel', self.stop);
self.parent[removeEventListener]('mousemove', self.move);
self.parent[removeEventListener]('touchmove', self.move);
// Delete them once they are removed. I think this makes a difference
// in memory usage with a lot of splits on one page. But I don't know for sure.
delete self.stop;
delete self.move;
a[removeEventListener]('selectstart', NOOP);
a[removeEventListener]('dragstart', NOOP);
b[removeEventListener]('selectstart', NOOP);
b[removeEventListener]('dragstart', NOOP);
a.style.userSelect = '';
a.style.webkitUserSelect = '';
a.style.MozUserSelect = '';
a.style.pointerEvents = '';
b.style.userSelect = '';
b.style.webkitUserSelect = '';
b.style.MozUserSelect = '';
b.style.pointerEvents = '';
self.gutter.style.cursor = '';
self.parent.style.cursor = '';
}
// startDragging calls `calculateSizes` to store the inital size in the pair object.
// It also adds event listeners for mouse/touch events,
// and prevents selection while dragging so avoid the selecting text.
function startDragging(e) {
// Alias frequently used variables to save space. 200 bytes.
var self = this;
var a = elements[self.a].element;
var b = elements[self.b].element;
// Call the onDragStart callback.
if (!self.dragging && options.onDragStart) {
options.onDragStart();
}
// Don't actually drag the element. We emulate that in the drag function.
e.preventDefault();
// Set the dragging property of the pair object.
self.dragging = true;
// Create two event listeners bound to the same pair object and store
// them in the pair object.
self.move = drag.bind(self);
self.stop = stopDragging.bind(self);
// All the binding. `window` gets the stop events in case we drag out of the elements.
global[addEventListener]('mouseup', self.stop);
global[addEventListener]('touchend', self.stop);
global[addEventListener]('touchcancel', self.stop);
self.parent[addEventListener]('mousemove', self.move);
self.parent[addEventListener]('touchmove', self.move);
// Disable selection. Disable!
a[addEventListener]('selectstart', NOOP);
a[addEventListener]('dragstart', NOOP);
b[addEventListener]('selectstart', NOOP);
b[addEventListener]('dragstart', NOOP);
a.style.userSelect = 'none';
a.style.webkitUserSelect = 'none';
a.style.MozUserSelect = 'none';
a.style.pointerEvents = 'none';
b.style.userSelect = 'none';
b.style.webkitUserSelect = 'none';
b.style.MozUserSelect = 'none';
b.style.pointerEvents = 'none';
// Set the cursor, both on the gutter and the parent element.
// Doing only a, b and gutter causes flickering.
self.gutter.style.cursor = cursor;
self.parent.style.cursor = cursor;
// Cache the initial sizes of the pair.
calculateSizes.call(self);
}
// 5. Create pair and element objects. Each pair has an index reference to
// elements `a` and `b` of the pair (first and second elements).
// Loop through the elements while pairing them off. Every pair gets a
// `pair` object, a gutter, and isFirst/isLast properties.
//
// Basic logic:
//
// - Starting with the second element `i > 0`, create `pair` objects with
// `a = i - 1` and `b = i`
// - Set gutter sizes based on the _pair_ being first/last. The first and last
// pair have gutterSize / 2, since they only have one half gutter, and not two.
// - Create gutter elements and add event listeners.
// - Set the size of the elements, minus the gutter sizes.
//
// -----------------------------------------------------------------------
// | i=0 | i=1 | i=2 | i=3 |
// | | isFirst | | isLast |
// | pair 0 pair 1 pair 2 |
// | | | | |
// -----------------------------------------------------------------------
var pairs = [];
elements = ids.map(function(id, i) {
// Create the element object.
var element = {
element: elementOrSelector(id),
size: sizes[i],
minSize: minSizes[i]
};
var pair;
if (i > 0) {
// Create the pair object with it's metadata.
pair = {
a: i - 1,
b: i,
dragging: false,
isFirst: i === 1,
isLast: i === ids.length - 1,
direction: direction,
parent: parent
};
// For first and last pairs, first and last gutter width is half.
pair.aGutterSize = gutterSize;
pair.bGutterSize = gutterSize;
if (pair.isFirst) {
pair.aGutterSize = gutterSize / 2;
}
if (pair.isLast) {
pair.bGutterSize = gutterSize / 2;
}
// if the parent has a reverse flex-direction, switch the pair elements.
if (
parentFlexDirection === 'row-reverse' ||
parentFlexDirection === 'column-reverse'
) {
var temp = pair.a;
pair.a = pair.b;
pair.b = temp;
}
}
// Determine the size of the current element. IE8 is supported by
// staticly assigning sizes without draggable gutters. Assigns a string
// to `size`.
//
// IE9 and above
if (!isIE8) {
// Create gutter elements for each pair.
if (i > 0) {
var gutterElement = gutter(i, direction);
setGutterSize(gutterElement, gutterSize);
gutterElement[addEventListener](
'mousedown',
startDragging.bind(pair)
);
gutterElement[addEventListener](
'touchstart',
startDragging.bind(pair)
);
parent.insertBefore(gutterElement, element.element);
pair.gutter = gutterElement;
}
}
// Set the element size to our determined size.
// Half-size gutters for first and last elements.
if (i === 0 || i === ids.length - 1) {
setElementSize(element.element, element.size, gutterSize / 2);
} else {
setElementSize(element.element, element.size, gutterSize);
}
var computedSize = element.element[getBoundingClientRect]()[dimension];
if (computedSize < element.minSize) {
element.minSize = computedSize;
}
// After the first iteration, and we have a pair object, append it to the
// list of pairs.
if (i > 0) {
pairs.push(pair);
}
return element;
});
function setSizes(newSizes) {
newSizes.forEach(function(newSize, i) {
if (i > 0) {
var pair = pairs[i - 1];
var a = elements[pair.a];
var b = elements[pair.b];
a.size = newSizes[i - 1];
b.size = newSize;
setElementSize(a.element, a.size, pair.aGutterSize);
setElementSize(b.element, b.size, pair.bGutterSize);
}
});
}
function destroy() {
pairs.forEach(function(pair) {
pair.parent.removeChild(pair.gutter);
elements[pair.a].element.style[dimension] = '';
elements[pair.b].element.style[dimension] = '';
});
}
if (isIE8) {
return {
setSizes: setSizes,
destroy: destroy
};
}
return {
setSizes: setSizes,
getSizes: function getSizes() {
return elements.map(function(element) {
return element.size;
});
},
collapse: function collapse(i) {
if (i === pairs.length) {
var pair = pairs[i - 1];
calculateSizes.call(pair);
if (!isIE8) {
adjust.call(pair, pair.size - pair.bGutterSize);
}
} else {
var pair$1 = pairs[i];
calculateSizes.call(pair$1);
if (!isIE8) {
adjust.call(pair$1, pair$1.aGutterSize);
}
}
},
destroy: destroy
};
};
return Split;
});

View File

@@ -1,140 +0,0 @@
.documentation {
font-family: Helvetica, sans-serif;
color: #666;
line-height: 1.5;
background: #f5f5f5;
}
.black {
color: #666;
}
.bg-white {
background-color: #fff;
}
h4 {
margin: 20px 0 10px 0;
}
.documentation h3 {
color: #000;
}
.border-bottom {
border-color: #ddd;
}
a {
color: #1184CE;
text-decoration: none;
}
.documentation a[href]:hover {
text-decoration: underline;
}
a:hover {
cursor: pointer;
}
.py1-ul li {
padding: 5px 0;
}
.max-height-100 {
max-height: 100%;
}
.height-viewport-100 {
height: 100vh;
}
section:target h3 {
font-weight:700;
}
.documentation td,
.documentation th {
padding: .25rem .25rem;
}
h1:hover .anchorjs-link,
h2:hover .anchorjs-link,
h3:hover .anchorjs-link,
h4:hover .anchorjs-link {
opacity: 1;
}
.fix-3 {
width: 25%;
max-width: 244px;
}
.fix-3 {
width: 25%;
max-width: 244px;
}
@media (min-width: 52em) {
.fix-margin-3 {
margin-left: 25%;
}
}
.pre, pre, code, .code {
font-family: Source Code Pro,Menlo,Consolas,Liberation Mono,monospace;
font-size: 14px;
}
.fill-light {
background: #F9F9F9;
}
.width2 {
width: 1rem;
}
.input {
font-family: inherit;
display: block;
width: 100%;
height: 2rem;
padding: .5rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
font-size: .875rem;
border-radius: 3px;
box-sizing: border-box;
}
table {
border-collapse: collapse;
}
.prose table th,
.prose table td {
text-align: left;
padding:8px;
border:1px solid #ddd;
}
.prose table th:nth-child(1) { border-right: none; }
.prose table th:nth-child(2) { border-left: none; }
.prose table {
border:1px solid #ddd;
}
.prose-big {
font-size: 18px;
line-height: 30px;
}
.quiet {
opacity: 0.7;
}
.minishadow {
box-shadow: 2px 2px 10px #f3f3f3;
}

View File

@@ -1,770 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset='utf-8' />
<title>seasoned-request 1.0.0 | Documentation</title>
<meta name='description' content='seasoned request app'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<link href='assets/bass.css' rel='stylesheet' />
<link href='assets/style.css' rel='stylesheet' />
<link href='assets/github.css' rel='stylesheet' />
<link href='assets/split.css' rel='stylesheet' />
</head>
<body class='documentation m0'>
<div class='flex'>
<div id='split-left' class='overflow-auto fs0 height-viewport-100'>
<div class='py1 px2'>
<h3 class='mb0 no-anchor'>seasoned-request</h3>
<div class='mb1'><code>1.0.0</code></div>
<input
placeholder='Filter'
id='filter-input'
class='col12 block input'
type='text' />
<div id='toc'>
<ul class='list-reset h5 py1-ul'>
<li><a
href='#getmovie'
class="">
getMovie
</a>
</li>
<li><a
href='#getshow'
class="">
getShow
</a>
</li>
<li><a
href='#gettmdblistbypath'
class="">
getTmdbListByPath
</a>
</li>
<li><a
href='#searchtmdb'
class="">
searchTmdb
</a>
</li>
<li><a
href='#searchtorrents'
class="">
searchTorrents
</a>
</li>
<li><a
href='#addmagnet'
class="">
addMagnet
</a>
</li>
<li><a
href='#request'
class="">
request
</a>
</li>
<li><a
href='#elasticsearchmoviesandshows'
class="">
elasticSearchMoviesAndShows
</a>
</li>
</ul>
</div>
<div class='mt1 h6 quiet'>
<a href='https://documentation.js.org/reading-documentation.html'>Need help reading this?</a>
</div>
</div>
</div>
<div id='split-right' class='relative overflow-auto height-viewport-100'>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='getmovie'>
getMovie
</h3>
</div>
<p>Fetches tmdb movie by id. Can optionally include cast credits in result object.</p>
<div class='pre p1 fill-light mt0'>getMovie</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>id</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number">number</a>)</code>
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>credits</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean">boolean</a>
= <code>false</code>)</code>
Include credits
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Tmdb response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='getshow'>
getShow
</h3>
</div>
<p>Fetches tmdb show by id. Can optionally include cast credits in result object.</p>
<div class='pre p1 fill-light mt0'>getShow</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>id</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number">number</a>)</code>
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>credits</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean">boolean</a>
= <code>false</code>)</code>
Include credits
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Tmdb response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='gettmdblistbypath'>
getTmdbListByPath
</h3>
</div>
<p>Fetches tmdb list by path.</p>
<div class='pre p1 fill-light mt0'>getTmdbListByPath</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>listPath</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
Path of list
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>page</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number">number</a>
= <code>1</code>)</code>
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Tmdb list response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='searchtmdb'>
searchTmdb
</h3>
</div>
<p>Fetches tmdb movies and shows by query.</p>
<div class='pre p1 fill-light mt0'>searchTmdb</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>query</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>page</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number">number</a>
= <code>1</code>)</code>
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Tmdb response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='searchtorrents'>
searchTorrents
</h3>
</div>
<p>Search for torrents by query</p>
<div class='pre p1 fill-light mt0'>searchTorrents</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>query</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>authorization_token</span> <code class='quiet'>(any)</code>
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>credits</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean">boolean</a>)</code>
Include credits
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Torrent response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='addmagnet'>
addMagnet
</h3>
</div>
<p>Add magnet to download queue.</p>
<div class='pre p1 fill-light mt0'>addMagnet</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>magnet</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
Magnet link
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>name</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean">boolean</a>)</code>
Name of torrent
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>tmdb_id</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean">boolean</a>)</code>
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Success/Failure response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='request'>
request
</h3>
</div>
<p>Request a movie or show from id. If authorization token is included the user will be linked
to the requested item.</p>
<div class='pre p1 fill-light mt0'>request</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>id</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number">number</a>)</code>
Movie or show id
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>type</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
Movie or show type
</div>
</div>
<div class='space-bottom0'>
<div>
<span class='code bold'>authorization_token</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>?
= <code>undefined</code>)</code>
To identify the requesting user
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
Success/Failure response
</section>
<section class='p2 mb2 clearfix bg-white minishadow'>
<div class='clearfix'>
<h3 class='fl m0' id='elasticsearchmoviesandshows'>
elasticSearchMoviesAndShows
</h3>
</div>
<p>Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and
Tv Shows. See tmdb docs for more info: <a href="https://developers.themoviedb.org/3/getting-started/daily-file-exports">https://developers.themoviedb.org/3/getting-started/daily-file-exports</a></p>
<div class='pre p1 fill-light mt0'>elasticSearchMoviesAndShows</div>
<div class='py1 quiet mt1 prose-big'>Parameters</div>
<div class='prose'>
<div class='space-bottom0'>
<div>
<span class='code bold'>query</span> <code class='quiet'>(<a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String">string</a>)</code>
</div>
</div>
</div>
<div class='py1 quiet mt1 prose-big'>Returns</div>
<code><a href="https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object">object</a></code>:
List of movies and shows matching query
</section>
</div>
</div>
<script src='assets/anchor.js'></script>
<script src='assets/split.js'></script>
<script src='assets/site.js'></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -11,8 +11,9 @@
"docs": "documentation build src/api.js -f html -o docs/api && documentation build src/api.js -f md -o docs/api.md"
},
"dependencies": {
"axios": "^0.15.3",
"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,12 +30,14 @@
"@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",
"sass-loader": "^5.0.1",
"schema-utils": "^2.4.1",
"vue-loader": "^10.0.0",
"vue-svg-inline-loader": "^1.3.1",
"vue-template-compiler": "2.6.10",
"webpack": "^2.2.0",
"webpack-dev-server": "^2.2.0"

View File

@@ -1,73 +1,78 @@
<template>
<div id="app">
<!-- Header and hamburger navigation -->
<navigation></navigation>
<!-- Header with search field -->
<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"></router-view>
<router-view class="content" :key="$route.fullPath"></router-view>
</div>
</template>
<script>
import Vue from 'vue'
import Navigation from '@/components/Navigation.vue'
import MoviePopup from '@/components/MoviePopup.vue'
import SearchInput from '@/components/SearchInput.vue'
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,
SearchInput
SearchInput,
DarkmodeToggle
},
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);
padding-top: $header-size;
margin-top: $header-size;
margin-left: 95px;
position: relative;
}
@@ -75,120 +80,84 @@ export default {
</style>
<style lang="scss">
@import "./src/scss/main";
// @import "./src/scss/main";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
*{
* {
box-sizing: border-box;
}
html, body{
html {
height: 100%;
}
body{
font-family: 'Roboto', sans-serif;
body {
margin: 0;
padding: 0;
font-family: "Roboto", sans-serif;
line-height: 1.6;
background: $c-light;
color: $c-dark;
&.hidden{
background: $background-color;
color: $text-color;
transition: background-color 0.5s ease, color 0.5s ease;
&.hidden {
overflow: hidden;
}
}
input, textarea, button{
font-family: 'Roboto', sans-serif;
h1,
h2,
h3 {
transition: color 0.5s ease;
}
figure{
a:any-link {
color: inherit;
}
input,
textarea,
button {
font-family: "Roboto", sans-serif;
}
figure {
padding: 0;
margin: 0;
}
img{
img {
display: block;
// max-width: 100%;
height: auto;
}
.wrapper{
.no-scroll {
overflow: hidden;
}
.wrapper {
position: relative;
}
.header{
.header {
position: fixed;
background: $c-white;
z-index: 15;
display: flex;
flex-direction: column;
@include tablet-min{
@include tablet-min {
width: calc(100% - 170px);
margin-left: 95px;
border-top: 0;
border-bottom: 0;
top: 0;
}
&__search{
display: flex;
position: relative;
z-index: 5;
width: 100%;
position: fixed;
top: 0;
right: 55px;
@include tablet-min{
position: relative;
height: 75px;
right: 0;
}
&-input{
display: block;
width: 100%;
padding: 15px 20px 15px 45px;
outline: none;
border: 0;
background-color: transparent;
color: $c-dark;
font-weight: 300;
font-size: 16px;
@include tablet-min{
padding: 15px 30px 15px 60px;
}
@include tablet-landscape-min{
padding: 15px 30px 15px 80px;
}
@include desktop-min{
padding: 15px 30px 15px 90px;
}
}
&-arrow {
height: 19px;
width: 30px;
display: flex;
align-self: center;
margin-right: 30px;
-moz-transition: all 0.5s ease;
-webkit-transition: all 0.5s ease;
transition: all 0.5s ease;
&.down {
-ms-transform: rotate(180deg);
-moz-transform: rotate(180deg);
-webkit-transform: rotate(180deg);
transform: rotate(180deg);
}
}
&-input:focus + &-icon{
fill: $c-dark;
}
}
}
// 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

@@ -1,7 +1,8 @@
import axios from 'axios'
import storage from '@/storage.js'
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,14 +26,21 @@ 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 axios.get(url.href)
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.error(`api error getting movie: ${id}`); throw error })
}
@@ -35,31 +50,79 @@ 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)
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.error(`api error getting show: ${id}`); throw error })
}
/**
* Fetches tmdb person by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getPerson = (id, credits=false) => {
const url = new URL('v2/person', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
if (credits) {
url.searchParams.append('credits', true)
}
return axios.get(url.href)
.catch(error => { console.error(`api error getting show: ${id}`); throw error })
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.error(`api error getting person: ${id}`); throw error })
}
/**
* Fetches tmdb list by path.
* @param {string} listPath Path of list
* Fetches tmdb list by name.
* @param {string} name List the fetch
* @param {number} [page=1]
* @returns {object} Tmdb list response
*/
const getTmdbListByPath = (listPath, page=1) => {
const url = new URL(listPath, SEASONED_URL)
const getTmdbMovieListByName = (name, page=1) => {
const url = new URL('v2/movie/' + name, SEASONED_URL)
url.searchParams.append('page', page)
// TODO - remove. this is temporary fix for user-requests endpoint (also import)
const headers = { authorization: storage.token }
return axios.get(url.href, { headers: headers })
.catch(error => { console.error(`api error getting list: ${listPath}, page: ${page}`); throw error })
return fetch(url.href, { headers: headers })
.then(resp => resp.json())
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error })
}
/**
* Fetches requested items.
* @param {number} [page=1]
* @returns {object} Request response
*/
const getRequests = (page=1) => {
const url = new URL('v2/request', SEASONED_URL)
url.searchParams.append('page', page)
const headers = { authorization: storage.token }
return fetch(url.href, { headers: headers })
.then(resp => resp.json())
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error })
}
const getUserRequests = (page=1) => {
const url = new URL('v1/user/requests', SEASONED_URL)
url.searchParams.append('page', page)
const headers = { authorization: localStorage.getItem('token') }
return fetch(url.href, { headers })
.then(resp => resp.json())
}
/**
@@ -68,12 +131,20 @@ const getTmdbListByPath = (listPath, 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 axios.get(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 })
}
@@ -86,12 +157,13 @@ const searchTmdb = (query, page=1) => {
* @returns {object} Torrent response
*/
const searchTorrents = (query, authorization_token) => {
const url = new URL('v1/pirate/search', SEASONED_URL)
const url = new URL('/api/v1/pirate/search', SEASONED_URL)
url.searchParams.append('query', query)
const headers = { authorization: storage.token }
return axios.get(url.href, { headers: headers })
return fetch(url.href, { headers: headers })
.then(resp => resp.json())
.catch(error => { console.error(`api error searching torrents: ${query}`); throw error })
}
@@ -105,15 +177,23 @@ const searchTorrents = (query, authorization_token) => {
const addMagnet = (magnet, name, tmdb_id) => {
const url = new URL('v1/pirate/add', SEASONED_URL)
const body = {
const body = JSON.stringify({
magnet: magnet,
name: name,
tmdb_id: tmdb_id
})
const headers = {
'Content-Type': 'application/json',
authorization: storage.token
}
const headers = { authorization: storage.token }
return axios.post(url.href, body, { headers: headers })
.catch(error => { console.error(`api error adding magnet: ${name}`); throw error })
return fetch(url.href, {
method: 'POST',
headers,
body
})
.then(resp => resp.json())
.catch(error => { console.error(`api error adding magnet: ${name} ${error}`); throw error })
}
// - - - Plex/Request - - -
@@ -172,33 +252,166 @@ 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/users/sign_in.json')
url.searchParams.append('user[login]', username)
url.searchParams.append('user[password]', password)
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'
}
return axios.post(url.href, { headers: headers })
.catch(error => { console.error(`api error authentication plex: ${username}`); throw error })
return fetch(url.href, { headers })
.then(resp => resp.json())
.then(response => response.link)
}
// - - - Seasoned user endpoints - - -
const register = (username, password) => {
const url = new URL('v1/user', SEASONED_URL)
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
}
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error('Unexpected error occured before receiving response. Error:', error)
// TODO log to sentry the issue here
throw error
})
}
const login = (username, password, throwError=false) => {
const url = new URL('v1/user/login', SEASONED_URL)
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
}
return fetch(url.href, options)
.then(resp => {
if (resp.status == 200)
return resp.json();
if (throwError)
throw resp;
else
console.error("Error occured when trying to sign in.\nError:", resp);
})
}
const getSettings = () => {
const settingsExists = (value) => {
if (value instanceof Object && value.hasOwnProperty('settings'))
return value;
throw "Settings does not exist in response object.";
}
const commitSettingsToStore = (response) => {
store.dispatch('userModule/setSettings', response.settings)
return response
}
const url = new URL('v1/user/settings', SEASONED_URL)
const authorization_token = localStorage.getItem('token')
const headers = authorization_token ? {
'Authorization': authorization_token,
'Content-Type': 'application/json'
} : {}
return fetch(url.href, { headers })
.then(resp => resp.json())
.then(settingsExists)
.then(commitSettingsToStore)
.then(response => response.settings)
.catch(error => { console.log('api error getting user settings'); throw error })
}
const updateSettings = (settings) => {
const url = new URL('v1/user/settings', SEASONED_URL)
const authorization_token = localStorage.getItem('token')
const headers = authorization_token ? {
'Authorization': authorization_token,
'Content-Type': 'application/json'
} : {}
return fetch(url.href, {
method: 'PUT',
headers,
body: JSON.stringify(settings)
})
.then(resp => resp.json())
.catch(error => { console.log('api error updating user settings'); throw error })
}
// - - - Authenticate with plex - - -
const linkPlexAccount = (username, password) => {
const url = new URL('v1/user/link_plex', SEASONED_URL)
const body = { username, password }
const headers = {
'Content-Type': 'application/json',
authorization: storage.token
}
return fetch(url.href, {
method: 'POST',
headers,
body: JSON.stringify(body)
})
.then(resp => resp.json())
.catch(error => { console.error(`api error linking plex account: ${username}`); throw error })
}
const unlinkPlexAccount = (username, password) => {
const url = new URL('v1/user/unlink_plex', SEASONED_URL)
const headers = {
'Content-Type': 'application/json',
authorization: storage.token
}
return fetch(url.href, {
method: 'POST',
headers
})
.then(resp => resp.json())
.catch(error => { console.error(`api error unlinking plex account: ${username}`); throw error })
}
// - - - User graphs - - -
const fetchChart = (urlPath, days, chartType) => {
const url = new URL('v1/user' + urlPath, SEASONED_URL)
url.searchParams.append('days', days)
url.searchParams.append('y_axis', chartType)
const authorization_token = localStorage.getItem('token')
const headers = authorization_token ? {
'Authorization': authorization_token,
'Content-Type': 'application/json'
} : {}
return fetch(url.href, { headers })
.then(resp => resp.json())
.catch(error => { console.log('api error fetching chart'); throw error })
}
// - - - Random emoji - - -
const getEmoji = () => {
const url = path.join(SEASONED_URL, 'v1/emoji')
const url = new URL('v1/emoji', SEASONED_URL)
return axios.get(url)
return fetch(url.href)
.then(resp => resp.json())
.catch(error => { console.log('api error getting emoji'); throw error })
}
@@ -252,4 +465,26 @@ const elasticSearchMoviesAndShows = (query) => {
export { getMovie, getShow, getTmdbListByPath, searchTmdb, searchTorrents, addMagnet, request, getRequestStatus, plexAuthenticate, getEmoji, elasticSearchMoviesAndShows }
export {
getMovie,
getShow,
getPerson,
getTmdbMovieListByName,
searchTmdb,
getUserRequests,
getRequests,
searchTorrents,
addMagnet,
request,
watchLink,
getRequestStatus,
linkPlexAccount,
unlinkPlexAccount,
register,
login,
getSettings,
updateSettings,
fetchChart,
getEmoji,
elasticSearchMoviesAndShows
}

View File

@@ -1,72 +1,75 @@
<template>
<section class="not-found">
<div class="not-found__content">
<h2 class="not-found__title">Page Not Found</h2>
</div>
</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 storage from '../storage.js'
import store from '@/store'
import SeasonedButton from '@/components/ui/SeasonedButton'
export default {
created(){
document.title = 'Page Not Found' + storage.pageTitlePostfix;
components: { SeasonedButton },
methods: {
goBack() {
this.$router.go(-1)
}
},
created() {
if (this.$popup.isOpen == true)
this.$popup.close()
}
}
</script>
<style lang="scss">
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.not-found{
width: 100%;
height: calc(100vh - 100px);
.button {
font-size: 1.2rem;
position: fixed;
top: 50%;
left: calc(50% + 46px);
transform: translate(-50%, -50%);
@include mobile {
top: 60%;
left: 50%;
font-size: 1rem;
width: content;
}
}
.not-found {
display: flex;
height: calc(100vh - var(--header-size));
background: url('~assets/pulp-fiction.jpg') no-repeat 50% 50%;
background-size: cover;
display: flex;
align-items: center;
justify-content: center;
@include tablet-min{
height: calc(100vh - 75px);
}
&:before{
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba($c-light, 0.7);
}
&-shortList{
width: 100%;
}
&__content{
width: 100%;
padding: 0 20px;
text-align: center;
@include tablet-min{
padding: 20px 0 0 0;
}
&-shortList {
width: 100%;
flex-direction: column;
&::before {
content: "";
position: absolute;
height: calc(100vh - var(--header-size));
width: 100%;
pointer-events: none;
background: $background-40;
}
&__title {
margin-top: 30vh;
font-size: 2.5rem;
font-weight: 500;
color: $text-color;
position: relative;
@include tablet-min {
font-size: 3.5rem;
}
}
&__title{
font-size: 24px;
font-weight: 500;
color: $c-dark;
position: relative;
margin: 0;
@include tablet-min{
font-size: 28px;
}
}
&__button{
position: relative;
margin-top: 20px;
}
}
</style>

View File

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

View File

@@ -2,27 +2,86 @@
<section>
<LandingBanner />
<movies-list v-for="item in homepageLists" :propList="item" :shortList="true"></movies-list>
<div v-for="list in lists">
<list-header :title="list.title" :link="'/list/' + list.route" />
<results-list :results="list.data" :shortList="true" />
<loader v-if="!list.data.length" />
</div>
</section>
</template>
<script>
import storage from '../storage.js'
import LandingBanner from '@/components/LandingBanner.vue'
import MoviesList from './MoviesList.vue'
import LandingBanner from "@/components/LandingBanner";
import ListHeader from "@/components/ListHeader";
import ResultsList from "@/components/ResultsList";
import Loader from "@/components/ui/Loader";
import { getTmdbMovieListByName, getRequests } from "@/api";
export default {
name: 'home',
components: { LandingBanner, MoviesList },
data(){
name: "home",
components: { LandingBanner, ResultsList, ListHeader, Loader },
data() {
return {
homepageLists: storage.homepageLists,
imageFile: 'dist/pulp-fiction.jpg'
imageFile: "/pulp-fiction.jpg",
requests: [],
nowplaying: [],
upcoming: [],
popular: []
};
},
computed: {
lists() {
return [
{
title: "Requests",
route: "request",
data: this.requests
},
{
title: "Now playing",
route: "now_playing",
data: this.nowplaying
},
{
title: "Upcoming",
route: "upcoming",
data: this.upcoming
},
{
title: "Popular",
route: "popular",
data: this.popular
}
];
}
},
created(){
document.title = 'TMDb';
storage.backTitle = document.title;
methods: {
fetchRequests() {
getRequests().then(results => (this.requests = results.results));
},
fetchNowPlaying() {
getTmdbMovieListByName("now_playing").then(
results => (this.nowplaying = results.results)
);
},
fetchUpcoming() {
getTmdbMovieListByName("upcoming").then(
results => (this.upcoming = results.results)
);
},
fetchPopular() {
getTmdbMovieListByName("popular").then(
results => (this.popular = results.results)
);
}
},
created() {
this.fetchRequests();
this.fetchNowPlaying();
this.fetchUpcoming();
this.fetchPopular();
}
}
</script>
};
</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,16 +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>
@@ -43,10 +44,11 @@ header {
background-repeat: no-repeat;
background-position: 50% 50%;
position: relative;
background-color: $c-dark;
@include tablet-min {
height: 284px;
}
&:before {
content: "";
position: absolute;
@@ -54,23 +56,26 @@ header {
left: 0;
width: 100%;
height: 100%;
background: rgba($c-light, 0.7);
background-color: $background-70;
transition: background-color 0.5s ease;
}
.container {
text-align: center;
position: relative;
transition: color 0.5s ease;
}
.title {
font-weight: 500;
font-size: 22px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: $c-dark;
color: $text-color;
margin: 0;
@include tablet-min{
font-size: 28px;
@include tablet-min {
font-size: 2.5rem;
}
}
@@ -78,35 +83,12 @@ header {
display: block;
font-size: 14px;
font-weight: 300;
color: $c-dark;
color: $text-color-70;
margin: 5px 0;
@include tablet-min{
font-size: 16px;
}
}
.link {
text-decoration: none;
color: $c-dark;
font-size: 13px;
font-weight: 300;
opacity: 0.7;
transition: opacity 0.5s ease;
&:hover {
opacity: 1;
}
span {
display: inline-block;
vertical-align: middle;
}
&-icon {
display: inline-block;
vertical-align: middle;
margin-right: 2px;
width: 16px;
height: 15px;
fill: $c-dark;
@include tablet-min {
font-size: 1.3rem;
}
}
}
</style>
</style>

View File

@@ -0,0 +1,117 @@
<template>
<header :class="{ sticky: sticky }">
<h2>{{ title }}</h2>
<div v-if="info instanceof Array" class="flex flex-direction-column">
<span v-for="item in info" class="info">{{ item }}</span>
</div>
<span v-else class="info">{{ info }}</span>
<router-link
v-if="link"
:to="link"
class="view-more"
:aria-label="`View all ${title}`"
>
View All
</router-link>
</header>
</template>
<script>
export default {
props: {
title: {
type: String,
required: true
},
sticky: {
type: Boolean,
required: false,
default: true
},
info: {
type: [String, Array],
required: false
},
link: {
type: String,
required: false
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/main";
header {
width: 100%;
min-height: 45px;
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 0.75rem;
padding-right: 0.75rem;
@include tablet-min {
min-height: 65px;
}
&.sticky {
background-color: $background-color;
position: sticky;
position: -webkit-sticky;
top: 0;
z-index: 4;
@include tablet-min {
top: $header-size;
}
}
h2 {
font-size: 1.4rem;
font-weight: 300;
text-transform: capitalize;
line-height: 1.4rem;
margin: 0;
color: $text-color;
}
.view-more {
font-size: 0.9rem;
font-weight: 300;
letter-spacing: 0.5px;
color: $text-color-70;
text-decoration: none;
transition: color 0.5s ease;
cursor: pointer;
&:after {
content: " →";
}
&:hover {
color: $text-color;
}
}
.info {
font-size: 13px;
font-weight: 300;
letter-spacing: 0.5px;
color: $text-color;
text-decoration: none;
text-align: right;
}
@include tablet-min {
padding-left: 1.25rem;
}
@include desktop-lg-min {
padding-left: 1.75rem;
}
}
</style>

123
src/components/ListPage.vue Normal file
View File

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

View File

@@ -1,134 +1,192 @@
<template>
<section class="movie">
<!-- HEADER w/ POSTER -->
<header class="movie__header" :style="{ 'background-image': movie && backdrop !== null ? 'url(' + ASSET_URL + ASSET_SIZES[1] + backdrop + ')' : '' }">
<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>{{ title }}</h1>
</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-action
:text="'Not yet in plex'" :iconRef="'#iconNot_exsits'"
:textActive="'Already in plex 🎉'" :iconRefActive="'#iconExists'"
:active="matched"></sidebar-action>
<sidebar-action
<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"
:text="'Request to be downloaded?'" :iconRef="'#iconSent'"
:iconRef="'#iconSent'"
:active="requested"
:textActive="'Requested to be downloaded'"
:active="requested"></sidebar-action>
<sidebar-action
v-if="admin" @click="showTorrents=!showTorrents"
:text="'Search for torrents'" :iconRef="'#icon_torrents'"
:active="showTorrents"></sidebar-action>
<sidebar-action
@click="openTmdb()"
:iconRef="'#icon_info'" :text="'See more info'"></sidebar-action>
>
Request to be downloaded?
</sidebar-list-element>
<sidebar-list-element
v-if="isPlexAuthenticated && matched"
@click="openInPlex"
:iconString="'⏯ '"
>
Watch in plex now!
</sidebar-list-element>
<sidebar-list-element
v-if="admin"
@click="showTorrents = !showTorrents"
:iconRef="'#icon_torrents'"
:active="showTorrents"
:supplementaryText="numberOfTorrentResults"
>
Search for torrents
</sidebar-list-element>
<sidebar-list-element @click="openTmdb" :iconRef="'#icon_info'">
See more info
</sidebar-list-element>
</div>
<!-- Loading placeholder -->
<div class="movie__actions text-input__loading" v-else>
<div class="movie__actions-link" v-for="_ in admin ? Array(4) : Array(3)">
<div class="movie__actions-text text-input__loading--line" style="margin:9px; margin-left: -3px;"></div>
<div
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.js'
import img from '@/directives/v-image.js'
import TorrentList from './TorrentList.vue'
import Person from './Person.vue'
import SidebarAction from './movie/SidebarAction.vue'
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 LoadingPlaceholder from './ui/LoadingPlaceholder.vue'
import { getMovie, getShow, request, getRequestStatus } from '@/api.js'
import {
getMovie,
getPerson,
getShow,
request,
getRequestStatus,
watchLink
} from "@/api";
export default {
props: ['id', 'type'],
components: { TorrentList, Person, LoadingPlaceholder, SidebarAction },
// 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,
@@ -136,82 +194,201 @@ export default {
matched: false,
userLoggedIn: storage.sessionId ? true : false,
requested: false,
admin: localStorage.getItem('admin'),
showTorrents: false
}
},
methods: {
parseResponse(resp) {
let movie = resp.data;
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)
document.title = movie.title + storage.pageTitlePostfix
},
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
},
admin: localStorage.getItem("admin") == "true" ? true : false,
showTorrents: false,
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);
}
}
},
beforeDestroy() {
document.title = this.prevDocumentTitle
},
created(){
this.prevDocumentTitle = document.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' });
})
computed: {
numberOfTorrentResults: () => {
let numTorrents = store.getters["torrentModule/resultCount"];
return numTorrents !== null ? numTorrents + " results" : null;
},
isPlexAuthenticated: () => {
return store.getters["userModule/isPlexAuthenticated"];
}
},
methods: {
parseResponse(movie) {
this.loading = false;
this.movie = { ...movie };
this.title = movie.title;
this.poster = movie.poster;
this.backdrop = movie.backdrop;
this.matched = movie.exists_in_plex || false;
this.checkIfRequested(movie).then(status => (this.requested = status));
console.log('admin: ', this.admin)
store.dispatch("documentTitle/updateTitle", movie.title);
this.setPosterSrc();
},
async checkIfRequested(movie) {
return await getRequestStatus(movie.id, movie.type);
},
setPosterSrc() {
const poster = this.$refs["poster-image"];
if (this.poster == null) {
poster.src = "/no-image.png";
return;
}
poster.src = `${this.ASSET_URL}${this.ASSET_SIZES[0]}${this.poster}`;
},
sendRequest() {
request(this.id, this.type, storage.token).then(resp => {
if (resp.success) {
this.requested = true;
}
});
},
openInPlex() {
watchLink(this.title, this.movie.year, storage.token).then(
watchLink => (window.location = watchLink)
);
},
openTmdb() {
const tmdbType = this.type === "show" ? "tv" : this.type;
window.location.href =
"https://www.themoviedb.org/" + tmdbType + "/" + this.id;
}
},
created() {
this.prevDocumentTitle = store.getters["documentTitle/title"];
if (this.type === "movie") {
getMovie(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
} else if (this.type == "person") {
getPerson(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
} else {
getShow(this.id, true)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: "404" });
});
}
},
beforeDestroy() {
store.dispatch("documentTitle/updateTitle", this.prevDocumentTitle);
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/loading-placeholder";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/main";
header {
$duration: 0.2s;
height: 250px;
transform: scaleY(1);
transition: height $duration ease;
transform-origin: top;
position: relative;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
background-color: $background-color;
display: flex;
align-items: center;
@include tablet-min {
height: 350px;
}
&:before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
background: $background-dark-85;
}
@include mobile {
&.compact {
height: 100px;
}
}
}
.movie__poster {
display: none;
@include desktop {
background: $background-color;
height: 0;
display: block;
position: absolute;
width: calc(45% - 40px);
top: 40px;
left: 40px;
> img {
width: 100%;
}
}
}
.truncate-toggle {
border: none;
background: none;
width: 100%;
display: flex;
align-items: center;
text-align: center;
color: $text-color;
> i {
font-style: unset;
font-size: 0.7rem;
transition: 0.3s ease all;
transform: rotateY(180deg);
}
&::before,
&::after {
content: "";
flex: 1;
border-bottom: 1px solid $text-color-50;
}
&::before {
margin-right: 1rem;
}
&::after {
margin-left: 1rem;
}
}
.movie {
&__wrap {
@@ -224,45 +401,12 @@ export default {
display: flex;
flex-wrap: wrap;
flex-direction: column;
@include tablet-min{
@include tablet-min {
flex-direction: row;
}
}
}
&__header {
height: 250px;
position: relative;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
background-color: $c-dark;
@include tablet-min {
height: 350px;
}
&:before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
background: rgba($c-dark, 0.85);
}
}
&__poster {
display: none;
@include tablet-min {
background: $c-white;
height: 0;
display: block;
position: absolute;
width: calc(45% - 40px);
top: 40px;
left: 40px;
background-color: $background-color;
color: $text-color;
}
}
@@ -282,7 +426,7 @@ export default {
&__title {
position: relative;
padding: 20px;
color: $c-green;
color: $green;
text-align: center;
width: 100%;
@include tablet-min {
@@ -299,16 +443,8 @@ export default {
font-size: 30px;
}
}
span {
display: block;
font-size: 14px;
font-weight: 300;
color: rgba($c-white, 0.7);
margin-top: 10px;
}
}
&__main {
background: $c-light;
min-height: calc(100vh - 250px);
@include tablet-min {
min-height: 0;
@@ -316,141 +452,105 @@ export default {
height: 100%;
}
&__actions {
text-align: center;
// min-height: 394px;
width: 100%;
order: 2;
padding: 20px;
border-top: 1px solid rgba($c-dark, 0.05);
@include tablet-min {
order: 1;
width: 45%;
padding: 185px 0 40px 40px;
border-top: 0;
}
&-link {
display: flex;
align-items: center;
text-decoration: none;
text-transform: uppercase;
color: rgba($c-dark, 0.5);
transition: color 0.5s ease;
font-size: 11px;
padding: 5px 0;
border-bottom: 1px solid rgba($c-dark, 0.05);
&:hover {
color: rgba($c-dark, 0.75);
}
&.active {
color: $c-dark;
}
&.pending {
color: #f8bd2d;
}
}
&-icon {
width: 18px;
height: 18px;
margin: 0 10px 0 0;
fill: rgba($c-dark, 0.5);
transition: fill 0.5s ease, transform 0.5s ease;
&.waiting {
transform: scale(0.8, 0.8);
}
&.pending {
fill: #f8bd2d;
}
}
&-link:hover &-icon {
fill: rgba($c-dark, 0.75);
cursor: pointer;
}
&-link.active &-icon {
fill: $c-green;
}
&-text {
display: block;
padding-top: 2px;
cursor: pointer;
margin:4.4px;
margin-left: -3px;
}
}
&__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);
}
}
&__actions + &__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;
font-size: 14px;
color: $c-green;
color: $green;
@include tablet-min {
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: $c-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

@@ -5,7 +5,7 @@
</template>
<script>
import Movie from './Movie.vue';
import Movie from './Movie';
export default {
components: { Movie }
}

View File

@@ -9,19 +9,36 @@
</template>
<script>
import Movie from './Movie.vue';
import Movie from './Movie';
export default {
props: ['id', 'type'],
props: {
id: {
type: Number,
required: true
},
type: {
type: String,
required: true
}
},
components: { Movie },
created(){
let that = this
window.addEventListener('keyup', function(e){
if (e.keyCode == 27) {
that.$popup.close()
methods: {
checkEventForEscapeKey(event) {
if (event.keyCode == 27) {
this.$popup.close()
}
})
}
},
created(){
window.addEventListener('keyup', this.checkEventForEscapeKey)
document.getElementsByTagName("body")[0].classList += " no-scroll";
},
beforeDestroy() {
window.removeEventListener('keyup', this.checkEventForEscapeKey)
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
}
}
</script>
@@ -36,7 +53,7 @@ export default {
z-index: 20;
width: 100%;
height: 100vh;
background: rgba($c-dark, 0.93);
background: rgba($dark, 0.93);
-webkit-overflow-scrolling: touch;
overflow: auto;
&__box{
@@ -44,7 +61,7 @@ export default {
max-width: 768px;
position: relative;
z-index: 5;
background: $c-dark;
background: $background-color-secondary;
padding-bottom: 50px;
@include tablet-min{
padding-bottom: 0;
@@ -71,7 +88,7 @@ export default {
left: 10px;
width: 20px;
height: 2px;
background: $c-white;
background: $white;
}
&:before{
transform: rotate(45deg);
@@ -80,7 +97,7 @@ export default {
transform: rotate(-45deg);
}
&:hover{
background: $c-green;
background: $green;
}
}
}

View File

@@ -1,328 +0,0 @@
<template>
<div>
<div class='movies-list' v-if="!error">
<header class='list-header'>
<h2 class='header__title'>{{ listTitle }}</h2>
<router-link class='header__view-more'
:to="'/list/' + list.route"
v-if='shortList'>
View All</router-link>
<div v-else style="line-height: 0;">
<span class='header__result-count' v-if="totalResults">{{ resultCount }} results</span>
<loading-placeholder v-else :count="1" lineClass='short nomargin'></loading-placeholder>
</div>
</header>
<!-- <ul class="filter">
<li class="filter-item" v-for="(item, index) in results" @click="applyFilter(item, index)" :class="{'active': item === selectedRelaseType}">{{ item.title }}</li>
</ul> -->
<ul class='results'>
<movies-list-item v-for='movie in results' :movie="movie" :shortList="shortList"></movies-list-item>
</ul>
<loader v-if="loader" />
<div class='end-section' v-if="!shortList">
<seasoned-button v-if="currentPage < totalPages" @click="loadMore">load more</seasoned-button>
</div>
</div>
<div v-else style="display: flex; height: 50vh; width: 100%; justify-content: center; align-items: center;">
<h1 v-if="error">{{ error }}</h1>
<h1 v-else>Unable to load list: {{ listTitle }}</h1>
</div>
</div>
</template>
<script>
import storage from '@/storage.js'
import MoviesListItem from '@/components/MoviesListItem.vue'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
import LoadingPlaceholder from '@/components/ui/LoadingPlaceholder.vue'
import Loader from '@/components/ui/Loader.vue'
import { searchTmdb, getTmdbListByPath } from '@/api.js'
export default {
props: {
shortList: {
type: Boolean,
default: false
},
propList: Object
},
components: { MoviesListItem, SeasonedButton, LoadingPlaceholder, Loader },
data() {
return {
listTitle: 'No listname found',
results: [],
currentPage: 1,
totalResults: 0,
totalPages: -1,
fetchingResults: false,
error: undefined,
loader: false,
filters: {
status: {
elms: ['all', 'requested', 'downloading', 'downloaded'],
selected: 0,
}
}
}
},
computed: {
resultCount() {
return this.totalResults.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ")
}
},
beforeMount() {
if (this.propList) {
this.list = this.propList
}
this.setPageFromUrlQuery()
this.parseURI()
},
mounted() {
setTimeout(() => {
if (this.results.length === 0 && this.error === undefined) {
this.loader = true
}
}, 200)
},
methods: {
setPageFromUrlQuery() {
if (this.$route.query.page)
this.currentPage = this.$route.query.page
console.log('url page param found', this.currentPage)
},
getListByName(name) {
return storage.homepageLists.filter(list => list.route === name)[0]
},
parseURI() {
const currentRouteName = this.$route.name
// route name is list - we are in a list view
if (currentRouteName === 'list') {
const nameParam = this.$route.params.name
if (this.getListByName(nameParam)) {
this.list = this.getListByName(nameParam)
this.listTitle = this.list.title
this.fetchListitems()
} else {
this.error = `Unable to load list: `
}
} // route name is search - we are searcing
else if (currentRouteName === 'search') {
if (this.$route.query.query) {
this.query = decodeURIComponent(this.$route.query.query)
this.listTitle = 'Search results: ' + this.query
this.fetchSearchItems()
} else {
this.error = 'Search query is not defined, please try again'
}
} // no matched route found - using prop to fetch list items
else {
this.listTitle = this.list.title
this.fetchListitems()
}
document.title = this.listTitle
},
// TODO these should receive a path not get it from list instance
fetchListitems() {
getTmdbListByPath(this.list.path, this.currentPage)
.then(this.parseResponse)
.catch(error => {
console.error(error)
this.error = 'Network error'
})
},
fetchSearchItems() {
searchTmdb(this.query, this.currentPage)
.then(this.parseResponse)
},
// TODO what parts are modular and what parts do we want the component to deal with
// if we pass in some object and then as we initialize we set to local variables.
// This way we call the http-api from outside and pass the response in to the component[0]
// Could also parse the response we are requesting then return a clean object we can
// pass down[1].
// [0] if this is done we should also take the page, total pages, total results and
// the list of results. Maybe also the title of the list or use local title as fallback?
// [1] an issue with this that duplicate code will be needed for doing the same with
// url params and paths.
// (What if we eliminated folder based routes and implemented the routes in hashes
// with single page applications today the navigation is simple enought that it
// would maybe not be needed to have a path-route but a hash-local.storage
// implementation; would allow sharing and remembering paths is just silly for most
// Single-Page-Applications that are tightly scoped applications)
parseResponse(response) {
const data = response.data
if (data.page > data.total_pages) {
console.error('You have reached the end')
this.error = 'You have reached the end'
return
}
if (this.results.length) {
this.results.push(...data.results)
} else {
this.results = this.shortList ? data.results.slice(0,12) : data.results
}
this.page = data.page
this.totalPages = data.total_pages
this.totalResults = data.total_results || data.results.length
this.loader = false
console.info(`Response from list: ${this.listTitle}`, { results: this.results, page: this.page, totalPages: this.totalPages, totalResults: this.totalResults })
},
loadMore(){
this.currentPage++;
console.log('path and name:', this.$route.path, this.$route.name)
let url = ''
if (this.$route.path.includes('list'))
url = `/#${this.$route.path}?page=${this.currentPage}`
else if (this.$route.path.includes('search'))
url = `/#/search?query=${this.query}&page=${this.currentPage}`
console.log('new url', url)
window.history.replaceState({}, 'foo', url)
this.parseURI()
},
// sort() {
// console.log(this.showFilters)
// },
// toggleFilter(item, index){
// this.showFilter = this.showFilter ? false : true;
// // this.results = this.results.filter(result => result.status != 'downloaded')
// },
// applyFilter(item, index) {
// this.filter = item;
// this.filters.status.selected = index;
// console.log('applied query filter: ', item, index)
// this.fetchCategory()
// }
},
watch: {
$route: function () {
console.log('updated route')
this.results = false
this.currentPage = 1
this.setPageFromUrlQuery()
this.parseURI()
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/elements";
.movies-list {
list-style: none;
display: flex;
flex-wrap: wrap;
padding: 15px;
.results {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
}
.list-header {
width: 100%;
display: flex;
flex-flow: row wrap;
align-items: center;
justify-content: space-between;
padding: 20px 10px;
@include tablet-min{
padding: 23px 15px;
}
@include tablet-landscape-min{
padding: 16px 25px;
}
@include desktop-min{
padding: 8px 30px;
}
.header__title {
line-height: 18px;
margin: 0;
font-size: 18px;
color: #081c24;
font-weight: 300;
// flex-basis: 50%;
text-transform: capitalize;
@include tablet-min{
font-size: 18px;
line-height: 18px;
}
}
.header__result-count {
font-size: 12px;
font-weight: 300;
letter-spacing: .5px;
color: rgba(8,28,36,.5);
text-align: right;
}
.header__view-more {
font-size: 13px;
font-weight: 300;
letter-spacing: .5px;
color: rgba($c-dark, 0.5);
text-decoration: none;
transition: color .5s ease;
cursor: pointer;
&:after{
content: " →";
}
&:hover{
color: $c-dark;
}
}
}
.end-section {
display: flex;
justify-content: center;
width: 100%;
margin: 1rem 0;
}
}
@import "./src/scss/media-queries";
.form__group-input {
padding: 10px 5px 10px 15px;
margin-left: 0;
height: 38px;
width: 150px;
font-size: 15px;
@include desktop-min {
width: 200px;
font-size: 17px;
}
}
</style>

View File

@@ -1,136 +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"
/>
<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.js'
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(){
return{
noImage: false
data() {
return {
poster: undefined,
observed: false,
posterSizes: [
{
id: "w500",
minWidth: 500
},
{
id: "w342",
minWidth: 342
},
{
id: "w185",
minWidth: 185
},
{
id: "w154",
minWidth: 0
}
]
};
},
computed: {
posterAltText: function () {
const type = this.movie.type || "";
const title = this.movie.title || this.movie.name;
return this.movie.poster
? `Poster for ${type} ${title}`
: `Missing image for ${type} ${title}`;
}
},
beforeMount() {
if (this.movie.poster != null) {
this.poster = "https://image.tmdb.org/t/p/w500" + this.movie.poster;
} else {
this.poster = "/no-image.png";
}
},
mounted() {
const poster = this.$refs["poster-image"];
if (poster == null) return;
const imageObserver = new IntersectionObserver((entries, imgObserver) => {
entries.forEach(entry => {
if (entry.isIntersecting && this.observed == false) {
const lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.className = lazyImage.className + " is-loaded";
this.observed = true;
}
});
});
imageObserver.observe(poster);
},
methods: {
// 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)
openMoviePopup(id, type) {
this.$popup.open(id, type);
}
}
}
};
</script>
<style lang="scss">
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.movies-item {
@import "./src/scss/main";
.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{
padding: 20px;
@include tablet-landscape-min {
padding: 15px;
width: 25%;
}
@include desktop-min{
padding: 30px;
@include desktop-min {
padding: 15px;
width: 20%;
}
@include desktop-lg-min{
padding: 20px;
width: 16.5%;
@include desktop-lg-min {
padding: 15px;
width: 12.5%;
}
&.shortList {
display: none;
&:nth-child(-n+6) { // show first 6
display: block;
}
@include tablet-landscape-min{
&:nth-child(-n+8) { // show first 8
display: block;
}
}
@include desktop-min{
&:nth-child(-n+10) { // show first 10
display: block;
}
}
@include desktop-lg-min{
display: block; // show all
}
&:hover &__info > p {
color: $text-color;
}
&__link{
&__poster {
text-decoration: none;
color: rgba($c-dark, 0.5);
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);
background: $c-white;
}
&__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($c-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;
@include mobile-ls-min{
font-size: 12px;
}
@include tablet-min{
font-size: 14px;
}
}
&__link:hover &__title{
color: $c-dark;
&: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,151 +1,174 @@
<template>
<div>
<nav class="nav">
<router-link class="nav__logo" :to="{name: 'home'}" exact title="Vue.js — TMDb App">
<svg class="nav__logo-image">
<use xlink:href="#svgLogo"></use>
</svg>
</router-link>
<div class="nav__hamburger" @click="toggleNav">
<div class="bar"></div>
<div class="bar"></div>
<div 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">
<!-- <img :src="item.icon" class="nav__link-icon"> -->
<svg class="nav__link-icon">
<use :xlink:href="'#icon_' + item.route"></use>
</svg>
<span class="nav__link-title">{{ item.title }}</span>
</div>
</router-link>
</li>
<li class="nav__item nav__item--profile">
<router-link class="nav__link nav__link--profile" :to="{name: 'signin'}" v-if="!userLoggedIn">
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use xlink:href="#iconLogin"></use>
</svg>
<span class="nav__link-title">Sign in</span>
</div>
</router-link>
<router-link class="nav__link nav__link--profile" :to="{name: 'profile'}" v-if="userLoggedIn">
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use xlink:href="#iconLogin"></use>
</svg>
<span class="nav__link-title">Profile</span>
</div>
</router-link>
</li>
</ul>
</nav>
<div class="spacer"></div>
</div>
<nav class="nav">
<router-link
class="nav__logo"
:to="{ name: 'home' }"
exact
title="Vue.js — TMDb App"
>
<svg class="nav__logo-image">
<use xlink:href="#svgLogo"></use>
</svg>
</router-link>
<div class="nav__hamburger" @click="toggleNav">
<div v-for="_ in 3" class="bar"></div>
</div>
<ul class="nav__list">
<li class="nav__item" v-for="item in listTypes">
<router-link class="nav__link" :to="'/list/' + item.route">
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use :xlink:href="'#icon_' + item.route"></use>
</svg>
<span class="nav__link-title">{{ item.title }}</span>
</div>
</router-link>
</li>
<li class="nav__item mobile-only"></li>
<li class="nav__item nav__item--profile">
<router-link
class="nav__link nav__link--profile"
:to="{ name: 'signin' }"
v-if="!userLoggedIn"
>
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use xlink:href="#iconLogin"></use>
</svg>
<span class="nav__link-title">Sign in</span>
</div>
</router-link>
<router-link
class="nav__link nav__link--profile"
:to="{ name: 'profile' }"
v-if="userLoggedIn"
>
<div class="nav__link-wrap">
<svg class="nav__link-icon">
<use xlink:href="#iconLogin"></use>
</svg>
<span class="nav__link-title">Profile</span>
</div>
</router-link>
</li>
</ul>
</nav>
</template>
<script>
import storage from '@/storage.js'
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(){
eventHub.$on('setUserStatus', this.setUserStatus);
created() {
// TODO move this to state manager
eventHub.$on("setUserStatus", this.setUserStatus);
}
}
};
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.spacer {
@include mobile-only {
width: 100%;
height: $header-size-mobile;
}
.icon {
width: 30px;
}
.nav {
transition: background 0.5s ease;
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: 100%;
height: 50px;
background: $c-white;
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;
height: $header-size-mobile;
&__logo {
width: 95px;
height: $header-size;
display: flex;
align-items: center;
justify-content: center;
background: $c-dark;
@include tablet-min{
width: 95px;
height: $header-size;
background: $background-nav-logo;
@include mobile-only {
align-items: flex-start;
padding-top: 0.5rem;
width: 55px;
}
&-image{
&-image {
width: 35px;
height: 31px;
fill: $c-green;
fill: $green;
transition: transform 0.5s ease;
@include tablet-min{
@include tablet-min {
width: 45px;
height: 40px;
}
}
&:hover &-image{
&:hover &-image {
transform: scale(1.04);
}
}
&__hamburger{
&__hamburger {
display: block;
position: fixed;
width: 55px;
height: 50px;
top: 0;
bottom: 1.5rem;
right: 0;
cursor: pointer;
background: $c-white;
z-index: 10;
border-left: 1px solid $c-light;
@include tablet-min{
border-left: 1px solid $background-color;
@include tablet-min {
display: none;
}
.bar{
.bar {
position: absolute;
width: 23px;
height: 1px;
background: rgba($c-dark, 0.5);
background-color: $text-color-70;
transition: all 300ms ease;
&:nth-child(1){
&:nth-child(1) {
left: 16px;
top: 17px;
}
&:nth-child(2){
&:nth-child(2) {
left: 16px;
top: 25px;
&:after {
@@ -155,19 +178,18 @@ export default {
top: 0px;
width: 23px;
height: 1px;
background: transparent;
transition: all 300ms ease;
}
}
&:nth-child(3){
&:nth-child(3) {
right: 15px;
top: 33px;
}
}
&--active{
.bar{
&--active {
.bar {
&:nth-child(1),
&:nth-child(3){
&:nth-child(3) {
width: 0;
}
&:nth-child(2) {
@@ -175,12 +197,13 @@ export default {
}
&:nth-child(2):after {
transform: rotate(-90deg);
background: rgba($c-dark, 0.5);
// background: rgba($c-dark, 0.5);
background-color: $text-color-70;
}
}
}
}
&__list{
&__list {
list-style: none;
padding: 0;
margin: 0;
@@ -189,23 +212,28 @@ export default {
position: fixed;
left: 0;
top: 50px;
background: rgba($c-white, 0.98);
border-top: 1px solid $c-light;
@include mobile-only{
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;
height: calc(100vh - 50px);
transition: all 0.5s ease;
background-color: $background-95;
text-align: left;
&--active{
&--active {
opacity: 1;
visibility: visible;
}
}
@include tablet-min{
@include tablet-min {
display: flex;
background: transparent;
position: relative;
display: block;
width: 100%;
@@ -213,31 +241,45 @@ export default {
top: 0;
}
}
&__item{
@include mobile-only{
display: inline-block;
&__item {
transition: background 0.5s ease, color 0.5s ease, border 0.5s ease;
background-color: $background-color-secondary;
color: $text-color-70;
@include mobile-only {
flex: 0 0 33.3%;
text-align: center;
width: 50%;
border-bottom: 1px solid $c-light;
&:nth-child(odd){
border-right: 1px solid $c-light;
border-bottom: 1px solid $background-color;
&:nth-child(odd) {
border-right: 1px solid $background-color;
&:last-child {
// flex: 0 0 100%;
}
}
}
@include tablet-min{
@include tablet-min {
width: 100%;
border-bottom: 1px solid $c-light;
&--profile{
border-bottom: 1px solid $text-color-5;
&--profile {
position: fixed;
right: 0;
top: 0;
width: $header-size;
height: $header-size;
border-bottom: 0;
border-left: 1px solid $c-light;
border-left: 1px solid $background-color;
}
}
&:hover,
.is-active {
color: $text-color;
background-color: $background-color;
}
}
&__link{
&__link {
background-color: inherit; // a elements have a transparent background
width: 100%;
display: flex;
flex-wrap: wrap;
@@ -248,8 +290,6 @@ export default {
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba($c-dark, 0.7);
transition: color 0.5s ease, background 0.5s ease;
position: relative;
cursor: pointer;
&-wrap {
@@ -258,49 +298,38 @@ export default {
align-items: center;
}
@include mobile-only{
@include mobile-only {
font-size: 10px;
padding: 20px 0;
}
@include tablet-min{
@include tablet-min {
width: 95px;
height: 95px;
font-size: 9px;
&--profile{
&--profile {
width: 75px;
height: 75px;
background: $c-white;
background-color: $background-color-secondary;
}
}
&-icon{
&-icon {
width: 20px;
height: 20px;
fill: rgba($c-dark, 0.7);
transition: fill 0.5s ease;
@include tablet-min{
fill: $text-color-70;
@include tablet-min {
width: 20px;
height: 20px;
margin-bottom: 5px;
}
}
&-title{
&-title {
margin-top: 5px;
display: block;
width: 100%;
}
&:hover{
color: $c-dark;
}
&:hover &-icon{
fill: $c-dark;
}
&.is-active{
color: $c-dark;
background: $c-light;
}
&.is-active &-icon{
fill: $c-dark;
&:hover &-icon,
&.is-active &-icon {
fill: $text-color;
}
}
}

View File

@@ -2,18 +2,19 @@
<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>
</header>
<settings v-if="showSettings"></settings>
<movies-list :propList="user_requestsList"></movies-list>
<list-header title="User requests" :info="resultCount" />
<results-list v-if="results" :results="results" />
</div>
<section class="not-found" v-if="!userLoggedIn">
@@ -28,62 +29,71 @@
</template>
<script>
import storage from '@/storage.js'
import MoviesList from '@/components/MoviesList.vue'
import Settings from '@/components/Settings.vue'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
import storage from '@/storage'
import store from '@/store'
import ListHeader from '@/components/ListHeader'
import ResultsList from '@/components/ResultsList'
import Settings from '@/components/Settings'
import SeasonedButton from '@/components/ui/SeasonedButton'
import { getEmoji } from '@/api.js'
// import CreatedLists from './CreatedLists.vue'
import { getEmoji, getUserRequests } from '@/api'
export default {
components: { MoviesList, Settings, SeasonedButton },
components: { ListHeader, ResultsList, Settings, SeasonedButton },
data(){
return{
userLoggedIn: '',
userName: '',
emoji: '',
showSettings: false,
user_requestsList: storage.user_requestsList
results: undefined,
totalResults: undefined,
showSettings: false
}
},
computed: {
resultCount() {
if (this.results === undefined)
return
const loadedResults = this.results.length
const totalResults = this.totalResults < 10000 ? this.totalResults : '∞'
return `${loadedResults} of ${totalResults} results`
},
username: () => store.getters['userModule/username']
},
methods: {
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(){
document.title = 'Profile' + storage.pageTitlePostfix;
storage.backTitle = document.title;
if(!localStorage.getItem('token')){
this.userLoggedIn = false;
} else {
this.userLoggedIn = true;
this.getUserInfo();
this.showSettings = window.location.toString().includes('settings=true')
getUserRequests()
.then(results => {
this.results = results.results
this.totalResults = results.total_results
})
getEmoji()
.then(resp => this.emoji = resp.data.emoji )
.then(resp => {
const { emoji } = resp
this.emoji = emoji
store.dispatch('documentTitle/updateEmoji', emoji)
})
}
}
}
@@ -93,6 +103,10 @@ export default {
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.button--group {
display: flex;
}
// DUPLICATE CODE
.profile{
&__header{
@@ -100,7 +114,17 @@ export default {
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid rgba($c-dark, 0.05);
border-bottom: 1px solid $text-color-5;
@include mobile-only {
flex-direction: column;
align-items: flex-start;
.button--group {
padding-top: 2rem;
}
}
@include tablet-min{
padding: 29px 30px;
}
@@ -115,7 +139,7 @@ export default {
margin: 0;
font-size: 16px;
line-height: 16px;
color: $c-dark;
color: $text-color;
font-weight: 300;
@include tablet-min{
font-size: 18px;

View File

@@ -1,202 +1,110 @@
<template>
<section class="profile">
<div class="profile__content">
<h2 class='settings__header'>Register new user</h2>
<section>
<h1>Register new user</h1>
<form class="form">
<seasoned-input text="username" icon="Email"
@inputValue="setValue('username', $event)"></seasoned-input>
<seasoned-input text="password" icon="Keyhole" type="password"
@inputValue="setValue('password', $event)"></seasoned-input>
<seasoned-input text="repeat password" icon="Keyhole" type="password"
@inputValue="setValue('passwordRepeat', $event)"></seasoned-input>
<seasoned-input placeholder="username" icon="Email" type="username" :value.sync="username" @enter="submit"/>
<transition name="message-fade">
<div class="message" :class="messageClass" v-if="showMessage">
<span class="message-text">{{ messageText }}</span>
<span class="message-dismiss" v-on:click="dismissMessage">X</span>
</div>
</transition>
<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"/>
<div class="form__group">
<seasoned-button @click="requestNewUser">Register</seasoned-button>
</div>
</form>
<div class="form__group">
<router-link class="form__group-link" :to="{name: 'signin'}" exact title="Sign in here">
<span class="form__group-signin">Sign in here</span>
</router-link>
</div>
<seasoned-button @click="submit">Register</seasoned-button>
<router-link class="link" to="/signin">Have a user? Sign in here</router-link>
</div>
<section class="not-found" v-if="userLoggedIn === false">
<div class="not-found__content">
<h2 class="not-found__title">Authentication Request Failed</h2>
<button class="not-found__button button">Log In</button>
</div>
</section>
<seasoned-messages :messages.sync="messages"></seasoned-messages>
</section>
</template>
<script>
import axios from 'axios'
import storage from '@/storage.js'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
import SeasonedInput from '@/components/ui/SeasonedInput.vue'
import { register } from '@/api'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedInput from '@/components/ui/SeasonedInput'
import SeasonedMessages from '@/components/ui/SeasonedMessages'
export default {
components: { SeasonedButton, SeasonedInput },
data(){
return{
userLoggedIn: '',
username: undefined,
password: undefined,
passwordRepeat: undefined,
showMessage: false,
messageClass: 'message-success',
messageText: 'hello world'
components: { SeasonedButton, SeasonedInput, SeasonedMessages },
data() {
return {
messages: [],
username: null,
password: null,
passwordRepeat: null
}
},
methods: {
requestNewUser(){
let username = this.username
let password = this.password
let password_re = this.passwordRepeat
submit() {
this.messages = [];
let { username, password, passwordRepeat } = this;
let verifyCredentials = this.checkCredentials(username, password, password_re);
if (verifyCredentials.verified) {
axios.post(`https://api.kevinmidboe.com/api/v1/user`, {
username: username,
password: password
})
.then(function(resp) {
let data = resp.data;
if (username == null || username.length == 0) {
this.messages.push({ type: 'error', title: 'Missing username' })
return
} else if (password == null || password.length == 0) {
this.messages.push({ type: 'error', title: 'Missing password' })
return
} else if (passwordRepeat == null || passwordRepeat.length == 0) {
this.messages.push({ type: 'error', title: 'Missing repeat password' })
return
} else if (passwordRepeat != password) {
this.messages.push({ type: 'error', title: 'Passwords do not match' })
return
}
this.registerUser(username, password)
},
registerUser(username, password) {
register(username, password, true)
.then(data => {
if (data.success){
this.msg(data.message, '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' })
}
}.bind(this))
.catch(function(error){
this.msg(error.response.data.error, 'warning')
}.bind(this));
}
else {
this.msg(verifyCredentials.reason, 'warning');
}
},
checkCredentials(username, password, password_re) {
if (password !== password_re) {
return {
verified: false,
reason: 'Passwords do not match'
}
}
else if (username === undefined) {
return {
verified: false,
reason: 'Please insert username'
}
}
else {
return {
verified: true,
reason: 'Verified credentials'
}
}
},
msg(text, status){
if (status === 'warning')
this.messageClass = 'message-warning';
else if (status === 'success')
this.messageClass = 'message-success';
else
this.messageClass = 'message-info';
this.messageText = text;
this.showMessage = true;
// setTimeout(() => this.showMessage = false, 3500);
},
dismissMessage(){
this.showMessage = false;
},
setValue(l, t) {
this[l] = t
})
.catch(error => {
if (error.status === 401) {
this.messages.push({ type: 'error', title: 'Access denied', message: 'Incorrect username or password' })
}
else {
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message })
}
});
},
logOut(){
localStorage.clear();
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'home' });
}
},
created(){
document.title = 'Profile' + storage.pageTitlePostfix;
storage.backTitle = document.title;
},
mounted(){
// this.$refs.email.focus();
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import "./src/scss/message";
// DUPLICATE CODE
.settings {
padding: 35px;
section {
padding: 1.3rem;
&__header {
@include tablet-min {
padding: 4rem;
}
h1 {
margin: 0;
line-height: 16px;
color: $c-dark;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
}
.profile__content {
padding: 35px;
display: flex;
justify-content: center;
flex-direction: column;
}
.center {
justify-content: center;
}
.form {
// TODO, fix this. if single child it adds weird margin
> div:last-child {
.link {
display: block;
width: max-content;
margin-top: 1rem;
}
&__group{
justify-content: unset;
&__input-icon {
margin-top: 8px;
height: 22px;
width: 22px;
}
&-input {
padding: 10px 5px 10px 45px;
height: 40px;
font-size: 17px;
width: 75%;
// @include desktop-min {
// width: 400px;
// }
}
}
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<ul class="results" :class="{'shortList': shortList}">
<movies-list-item v-for='movie in results' :movie="movie" />
</ul>
</template>
<script>
import MoviesListItem from '@/components/MoviesListItem'
export default {
components: { MoviesListItem },
props: {
results: {
type: Array,
required: true
},
shortList: {
type: Boolean,
required: false,
default: false
}
}
}
</script>
<style lang="scss" scoped>
@import './src/scss/media-queries';
.results {
display: flex;
flex-wrap: wrap;
margin: 0;
padding: 0;
list-style: none;
&.shortList > li {
display: none;
&:nth-child(-n+4) {
display: block;
}
}
}
@include tablet-min {
.results.shortList > li:nth-child(-n+6) {
display: block;
}
}
@include tablet-landscape-min {
.results.shortList > li:nth-child(-n+8) {
display: block;
}
}
@include desktop-min {
.results.shortList > li:nth-child(-n+10) {
display: block;
}
}
@include desktop-lg-min {
.results.shortList > li:nth-child(-n+16) {
display: block;
}
}
</style>

138
src/components/Search.vue Normal file
View File

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

View File

@@ -1,176 +1,290 @@
<template>
<div>
<div class="search">
<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"><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.vue'
import SeasonedButton from "@/components/ui/SeasonedButton";
import ToggleButton from "@/components/ui/ToggleButton";
import { elasticSearchMoviesAndShows } from '@/api.js'
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--
},
handleInput(e){
this.selectedResult = 0
this.$emit('input', this.query);
this.focus = true;
this.selectedResult--;
const input = this.$refs.input;
const textLength = input.value.length;
if (! this.focus) {
setTimeout(() => {
input.focus();
input.setSelectionRange(textLength, textLength + 1);
}, 1);
},
openResult(item, index) {
this.selectedResult = index;
this.$popup.open(item.id, item.type);
},
handleInput(e) {
this.selectedResult = 0;
this.$emit("input", this.query);
if (!this.focus) {
this.focus = true;
}
elasticSearchMoviesAndShows(this.query)
.then(resp => {
const data = resp.data.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') {
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: index
}
} else if (index === 'show') {
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: index
}
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;
@@ -179,7 +293,7 @@ export default {
z-index: 5;
min-height: $header-size;
right: 0px;
background-color: white;
background-color: $background-color-secondary;
@include mobile-only {
position: fixed;
@@ -188,7 +302,11 @@ export default {
width: calc(100%);
}
&--results {
.not-found {
font-weight: 400;
}
&-results {
padding-left: 60px;
width: 100%;
@@ -203,75 +321,82 @@ 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;
border-bottom: 2px solid transparent;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
color: $text-color-50;
&.active, &:hover, &:active {
color: $c-dark;
border-bottom: 2px solid black;
&.active,
&:hover,
&:active {
color: $text-color;
border-bottom: 2px solid $text-color;
}
}
}
}
.search {
height: $header-size-mobile;
height: $header-size;
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);
// width: 100%;
top: 0;
bottom: 0;
right: 55px;
@include tablet-min{
@include tablet-min {
position: relative;
height: $header-size;
width: 100%;
right: 0px;
}
input {
// height: 75px;
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: transparent;
color: $c-dark;
background-color: $background-color-secondary;
font-weight: 300;
font-size: 19px;
color: $text-color;
transition: background-color 0.5s ease, color 0.5s ease;
@include tablet-min {
height: calc($header-size);
padding: 13px 30px 13px 60px;
}
}
&--icon{
&-icon {
width: 20px;
height: 20px;
fill: rgba($c-dark, 0.5);
fill: $text-color-50;
transition: fill 0.5s ease;
pointer-events: none;
position: absolute;
left: 15px;
top: 15px;
@include tablet-min{
@include tablet-min {
top: 27px;
left: 25px;
}
}
}
</style>
</style>

View File

@@ -3,25 +3,33 @@
<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 text="plex username" icon="Email"
@inputValue="setValue('plexUsername', $event)"/>
<seasoned-input text="plex password" icon="Keyhole" type="password"
@inputValue="setValue('plexPassword', $event)"/>
<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>
<form class="form">
<seasoned-input placeholder="plex username" icon="Email" :value.sync="plexUsername"/>
<seasoned-input placeholder="plex password" icon="Keyhole" type="password"
:value.sync="plexPassword" @submit="authenticatePlex" />
<seasoned-button @click="authenticatePlex">link plex account</seasoned-button>
</form>
</div>
<div v-else>
<span class="settings__info">Awesome, your account is already authenticated with plex! Enjoy viewing your seasoned search history, plex watch history and real-time torrent download progress.</span>
<seasoned-button @click="unauthenticatePlex">un-link plex account</seasoned-button>
</div>
<seasoned-messages :messages.sync="messages" />
<hr class='setting__divider'>
<h3 class='settings__header'>Change password</h3>
<form class="form">
<seasoned-input text="new password" icon="Keyhole" type="password"
@inputValue="setValue('newPass', $event)"/>
<seasoned-input text="repeat new password" icon="Keyhole" type="password"
@inputValue="setValue('newPassConfirm', $event)"/>
<seasoned-input placeholder="new password" icon="Keyhole" type="password"
:value.sync="newPassword" />
<seasoned-input placeholder="repeat new password" icon="Keyhole" type="password"
:value.sync="newPasswordRepeat" />
<seasoned-button @click="changePassword">change password</seasoned-button>
</form>
@@ -42,49 +50,78 @@
</template>
<script>
import storage from '@/storage.js'
import SeasonedInput from '@/components/ui/SeasonedInput.vue'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
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.js'
import { getSettings, updateSettings, linkPlexAccount, unlinkPlexAccount } from '@/api'
export default {
components: { SeasonedInput, SeasonedButton },
components: { SeasonedInput, SeasonedButton, SeasonedMessages },
data(){
return{
userLoggedIn: '',
plexUsername: undefined,
plexPassword: undefined,
newPass: undefined,
newPassConfirm: undefined
messages: [],
plexUsername: null,
plexPassword: null,
newPassword: null,
newPasswordRepeat: null,
emoji: null
}
},
computed: {
hasPlexUser: function() {
return this.settings && this.settings['plex_userid']
},
settings: {
get: () => {
return store.getters['userModule/settings']
},
set: function(newSettings) {
store.dispatch('userModule/setSettings', newSettings)
}
}
},
methods: {
setValue(l, t) {
console.log('l, t', l, t)
this[l] = t
},
changePassword() {
return
},
authenticatePlex() {
async authenticatePlex() {
let username = this.plexUsername
let password = this.plexPassword
plexAuthenticate(username, password)
.then((resp) => {
let data = resp.data;
console.log('response from plex:', data.user)
const response = await linkPlexAccount(username, password)
this.messages.push({
type: response.success ? 'success' : 'error',
title: response.success ? 'Authenticated with plex' : 'Something went wrong',
message: response.message
})
.catch((error) => {
console.log('error: ', error)
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(){
document.title = 'Settings' + storage.pageTitlePostfix;
storage.backTitle = document.title;
if (localStorage.getItem('token')){
const token = localStorage.getItem('token') || false;
if (token){
this.userLoggedIn = true
}
}
@@ -94,6 +131,7 @@ export default {
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
a {
text-decoration: none;
}
@@ -123,12 +161,16 @@ a {
}
}
.settings {
padding: 35px;
padding: 3rem;
@include mobile-only {
padding: 1rem;
}
&__header {
margin: 0;
line-height: 16px;
color: $c-dark;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
@@ -141,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

@@ -1,158 +1,114 @@
<template>
<section class="profile">
<div class="profile__content">
<h2 class='settings__header'>Sign in</h2>
<section>
<h1>Sign in</h1>
<form class="form">
<div class="form__buffer"></div>
<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-input text="username" icon="Email" type="username"
@inputValue="setValue('username', $event)" />
<seasoned-input text="username" icon="Keyhole" type="password"
@inputValue="setValue('password', $event)" />
<seasoned-button @click="submit">sign in</seasoned-button>
<router-link class="link" to="/register">Don't have a user? Register here</router-link>
<seasoned-button @click="signin">sign in</seasoned-button>
<transition name="message-fade">
<div class="message" :class="messageClass" v-if="showMessage">
<span class="message-text">{{ messageText }}</span>
<span class="message-dismiss" @click="showMessage=false">X</span>
</div>
</transition>
</form>
<div class="form__group">
<router-link class="form__group-link" :to="{name: 'register'}" exact title="Sign in here">
<span class="form__group-signin">Don't have a user? Register here</span>
</router-link>
</div>
</div>
<seasoned-messages :messages.sync="messages"></seasoned-messages>
</section>
</template>
<script>
import axios from 'axios'
import storage from '../storage.js'
import SeasonedInput from '@/components/ui/SeasonedInput.vue'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
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 },
components: { SeasonedInput, SeasonedButton, SeasonedMessages },
data(){
return{
userLoggedIn: '',
showMessage: false,
messageClass: 'message-success',
messageText: 'hello world',
username: undefined,
password: undefined
messages: [],
username: null,
password: null
}
},
methods: {
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(function (resp){
let data = resp.data;
if (data.success){
localStorage.setItem('token', data.token);
localStorage.setItem('username', username);
localStorage.setItem('admin', data.admin);
this.userLoggedIn = true;
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
}.bind(this))
.catch(function (error){
if (error.message.endsWith('401'))
this.msg('Incorrect username or password ', 'warning')
else
this.msg(error.message, 'warning')
}.bind(this));
},
msg(text, status){
if (status === 'warning')
this.messageClass = 'message-warning';
else if (status === 'success')
this.messageClass = 'message-success';
else
this.messageClass = 'message-info';
this.messageText = text;
this.showMessage = true;
// setTimeout(() => this.showMessage = false, 3500);
},
toggleView(){
this.register = false;
if (username == null || username.length == 0) {
this.messages.push({ type: 'error', title: 'Missing username' })
return
}
if (password == null || password.length == 0) {
this.messages.push({ type: 'error', title: 'Missing password' })
return
}
this.signin(username, password)
},
signin(username, password) {
login(username, password, true)
.then(data => {
if (data.success){
const jwtData = parseJwt(data.token)
localStorage.setItem('token', data.token);
localStorage.setItem('username', jwtData['username']);
localStorage.setItem('admin', jwtData['admin'] || false);
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'profile' })
}
})
.catch(error => {
if (error.status === 401) {
this.messages.push({ type: 'error', title: 'Access denied', message: 'Incorrect username or password' })
}
else {
this.messages.push({ type: 'error', title: 'Unexpected error', message: error.message })
}
});
}
},
created(){
document.title = 'Sign in' + storage.pageTitlePostfix;
storage.backTitle = document.title;
if (this.userLoggedIn == true) {
this.$router.push({ name: 'profile' })
}
},
mounted(){
// this.$refs.email.focus();
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/message";
// DUPLICATE CODE
.settings {
padding: 35px;
section {
padding: 1.3rem;
&__header {
@include tablet-min {
padding: 4rem;
}
h1 {
margin: 0;
line-height: 16px;
color: $c-dark;
color: $text-color;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
}
.profile__content {
padding: 35px;
display: flex;
justify-content: center;
flex-direction: column;
}
.form {
> div:last-child {
.link {
display: block;
width: max-content;
margin-top: 1rem;
}
&__group{
justify-content: unset;
&__input-icon {
margin-top: 8px;
height: 22px;
width: 22px;
}
&-input {
padding: 10px 5px 10px 45px;
height: 40px;
font-size: 17px;
width: 75%;
// @include desktop-min {
// width: 400px;
// }
}
}
}
</style>

View File

@@ -1,53 +1,108 @@
<template>
<div v-if="show">
<h2 class="title">torrents: {{ query }}</h2>
<div v-if="show" class="container">
<h2 class="torrentHeader-text">Searching for: {{ editedSearchQuery || query }}</h2>
<!-- <div class="torrentHeader">
<span class="torrentHeader-text">Searching for:&nbsp;</span>
<span id="search" :contenteditable="editSearchQuery ? true : false" class="torrentHeader-text editable">{{ editedSearchQuery || query }}</span>
<svg v-if="!editSearchQuery" class="torrentHeader-editIcon" @click="toggleEditSearchQuery">
<use xlink:href="#icon_radar"></use>
</svg>
<svg v-else class="torrentHeader-editIcon" @click="toggleEditSearchQuery">
<use xlink:href="#icon_check"></use>
</svg>
</div> -->
<div v-if="listLoaded">
<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>
<div v-if="torrents.length > 0">
<!-- <ul class="filter">
<li class="filter-item" v-for="(item, index) in release_types" @click="applyFilter(item, index)" :class="{'active': item === selectedRelaseType}">{{ item }}</li>
</ul> -->
<toggle-button :options="release_types" :selected.sync="selectedRelaseType" class="toggle"></toggle-button>
<table>
<tr class="table__header noselect">
<th @click="sortTable('name')">
<span>Name</span>
<span v-if="prevCol === 'name' && direction"></span>
<span v-if="prevCol === 'name' && !direction"></span>
</th>
<th @click="sortTable('seed')">
<span>Seed</span>
<span v-if="prevCol === 'seed' && direction"></span>
<span v-if="prevCol === 'seed' && !direction"></span>
</th>
<th @click="sortTable('size')">
<span>Size</span>
<span v-if="prevCol === 'size' && direction"></span>
<span v-if="prevCol === 'size' && !direction"></span>
<th>
<span>Magnet</span>
</th>
</tr>
<tr v-for="torrent in torrents" class="table__content">
<td @click="expand($event, torrent.name)">{{ torrent.name }}</td>
<td @click="expand($event, torrent.name)">{{ torrent.seed }}</td>
<td @click="expand($event, torrent.name)">{{ torrent.size }}</td>
<td @click="sendTorrent(torrent.magnet, torrent.name, $event)" class="download">
<svg class="download__icon"><use xlink:href="#iconUnmatched"></use></svg>
</td>
</tr>
</table>
<table>
<tr class="table__header noselect">
<th @click="sortTable('name')" :class="selectedSortableClass('name')">
<span>Name</span>
<span v-if="prevCol === 'name' && direction"></span>
<span v-if="prevCol === 'name' && !direction"></span>
</th>
<th @click="sortTable('seed')" :class="selectedSortableClass('seed')">
<span>Seed</span>
<span v-if="prevCol === 'seed' && direction"></span>
<span v-if="prevCol === 'seed' && !direction"></span>
</th>
<th @click="sortTable('size')" :class="selectedSortableClass('size')">
<span>Size</span>
<span v-if="prevCol === 'size' && direction"></span>
<span v-if="prevCol === 'size' && !direction"></span>
<th>
<span>Magnet</span>
</th>
</tr>
<tr v-for="torrent in torrents" class="table__content">
<td @click="expand($event, torrent.name)">{{ torrent.name }}</td>
<td @click="expand($event, torrent.name)">{{ torrent.seed }}</td>
<td @click="expand($event, torrent.name)">{{ torrent.size }}</td>
<td @click="sendTorrent(torrent.magnet, torrent.name, $event)" class="download">
<svg class="download__icon"><use xlink:href="#iconUnmatched"></use></svg>
</td>
</tr>
</table>
<div style="
display: flex;
justify-content: center;
padding: 1rem;
">
<seasonedButton @click="resetTorrentsAndToggleEditSearchQuery">Edit search query</seasonedButton>
</div>
</div>
<div v-else style="display: flex;
padding-bottom: 2rem;
justify-content: center;
flex-direction: column;
width: 100%;
align-items: center;">
<h2>No results found</h2>
<br />
<div class="editQuery" v-if="editSearchQuery">
<seasonedInput placeholder="Torrent query" icon="_torrents" :value.sync="editedSearchQuery" @enter="fetchTorrents(editedSearchQuery)" />
<div style="height: 45px; width: 5px;"></div>
<seasonedButton @click="fetchTorrents(editedSearchQuery)">Search</seasonedButton>
</div>
<seasonedButton @click="toggleEditSearchQuery" :active="editSearchQuery ? true : false">Edit search query</seasonedButton>
</div>
<i v-else class="torrentloader"></i>
</div>
<div v-else class="torrentloader"><i></i></div>
</div>
</template>
<script>
import storage from '@/storage.js'
import { sortableSize } from '@/utils.js'
import { searchTorrents, addMagnet } from '@/api.js'
import storage from '@/storage'
import store from '@/store'
import { sortableSize } from '@/utils'
import { searchTorrents, addMagnet } from '@/api'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedInput from '@/components/ui/SeasonedInput'
import ToggleButton from '@/components/ui/ToggleButton'
export default {
components: { SeasonedButton, SeasonedInput, ToggleButton },
props: {
query: {
type: String,
@@ -58,51 +113,73 @@ export default {
require: true
},
tmdb_type: String,
admin: String,
admin: Boolean,
show: Boolean
},
data() {
return {
listLoaded: false,
torrents: undefined,
torrents: [],
torrentResponse: undefined,
currentPage: 0,
prevCol: '',
direction: false,
release_types: ['all'],
selectedRelaseType: 'all'
selectedRelaseType: 'all',
editSearchQuery: false,
editedSearchQuery: ''
}
},
beforeMount() {
if (localStorage.getItem('admin')) {
this.fetchTorrents()
}
store.dispatch('torrentModule/reset')
},
watch: {
selectedRelaseType: function(newValue) {
this.applyFilter(newValue)
}
},
methods: {
selectedSortableClass(headerName) {
return headerName === this.prevCol ? 'active' : ''
},
resetTorrentsAndToggleEditSearchQuery() {
this.torrents = []
this.toggleEditSearchQuery()
},
toggleEditSearchQuery() {
this.editSearchQuery = !this.editSearchQuery;
},
expand(event, name) {
const existingExpandedElement = document.getElementsByClassName('expanded')[0]
const clickedElement = event.target.parentNode;
const scopedStyleDataVariable = Object.keys(clickedElement.dataset)[0]
if (existingExpandedElement) {
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({
@@ -112,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) => {
@@ -128,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':
@@ -184,38 +259,43 @@ export default {
this.torrents = torrents.filter(torrent => torrent.release_type.includes(item))
this.sortTable(this.prevCol, true)
},
fetchTorrents(){
searchTorrents(this.query, 'all', this.currentPage, storage.token)
.then(resp => {
let data = resp.data;
console.log('data results', data.results);
this.torrentResponse = data.results;
this.torrents = data.results;
updateResultCountInStore() {
store.dispatch('torrentModule/setResults', this.torrents)
store.dispatch('torrentModule/setResultCount', this.torrentResponse.length)
},
fetchTorrents(query=undefined){
this.listLoaded = false;
this.editSearchQuery = false;
searchTorrents(query || this.query, 'all', this.currentPage, storage.token)
.then(data => {
this.torrentResponse = [...data.results];
this.torrents = data.results;
this.listLoaded = true;
})
.then(this.updateResultCountInStore)
.then(this.findRelaseTypes)
.catch(e => {
const error = e.toString()
this.errorMessage = error.indexOf('401') != -1 ? 'Permission denied' : 'Nothing found';
this.listLoaded = true;
})
.then(this.findRelaseTypes)
.catch(e => {
const error = e.toString()
this.errorMessage = error.indexOf('401') != -1 ? 'Permission denied' : 'Nothing found';
this.listLoaded = true;
});
});
},
}
}
</script>
<style lang="scss">
<style lang="scss" scoped>
@import "./src/scss/variables";
.expanded {
display: flex;
margin: 0 1rem;
padding: 0.25rem 1rem;
max-width: 100%;
border-left: 1px solid rgba($c-dark, 0.5);
border-right: 1px solid rgba($c-dark, 0.5);
border-bottom: 1px solid rgba($c-dark, 0.5);
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%;
@@ -227,16 +307,50 @@ export default {
@import "./src/scss/media-queries";
@import "./src/scss/elements";
.title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
text-align: center;
font-size: 14px;
color: $c-green;
.toggle {
max-width: unset !important;
margin: 1rem 0;
}
.container {
background-color: $background-color;
padding: 0 1rem;
}
.torrentHeader {
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 20px;
@include tablet-min{
font-size: 16px;
&-text {
font-weight: 400;
text-transform: uppercase;
font-size: 14px;
color: $green;
text-align: center;
margin: 0;
@include tablet-min {
font-size: 16px
}
&.editable {
cursor: pointer;
}
}
&-editIcon {
margin-left: 10px;
margin-top: -3px;
width: 22px;
height: 22px;
&:hover {
fill: $green;
cursor: pointer;
}
}
}
@@ -249,10 +363,9 @@ table {
.table__content, .table__header {
display: flex;
padding: 0;
margin: 0 1rem;
border-left: 1px solid rgba($c-dark, 0.8);
border-right: 1px solid rgba($c-dark, 0.8);
border-bottom: 1px solid rgba($c-dark, 0.8);
border-left: 1px solid $text-color;
border-right: 1px solid $text-color;
border-bottom: 1px solid $text-color;
th, td {
display: flex;
@@ -264,6 +377,7 @@ table {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
min-width: 75px;
}
th:first-child, td:first-child {
@@ -297,7 +411,7 @@ table {
.table__content {
td:not(:last-child) {
border-right: 1px solid rgba($c-dark, 0.8);
border-right: 1px solid $text-color;
}
}
@@ -309,12 +423,12 @@ table {
}
.table__header {
background-color: white;
color: $c-dark;
color: $text-color;
text-transform: uppercase;
cursor: pointer;
background-color: $background-color-secondary;
border-top: 1px solid rgba($c-dark, 0.8);
border-top: 1px solid $text-color;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
@@ -340,47 +454,66 @@ table {
}
th:not(:last-child) {
border-right: 1px solid rgba($c-dark, 0.8);
border-right: 1px solid $text-color;
}
}
.editQuery {
display: flex;
width: 70%;
justify-content: center;
@include mobile-only {
width: 90%;
}
}
.download {
&__icon {
fill: rgba($c-dark, 0.6);
fill: $text-color-70;
height: 1.2rem;
&:hover {
fill: $c-dark;
fill: $text-color;
cursor: pointer;
}
}
&.active &__icon {
fill: $c-green;
fill: $green;
}
}
.torrentloader{
animation: load 1s linear infinite;
border: 2px solid $c-dark;
border-radius: 50%;
display: block;
height: 30px;
left: 50%;
margin: 2rem auto;
width: 30px;
&:after {
border: 5px solid $c-green;
.torrentloader {
width: 100%;
padding: 2rem 0;
i {
animation: load 1s linear infinite;
border: 2px solid $text-color;
border-radius: 50%;
content: '';
left: 10px;
position: absolute;
top: 16px;
display: block;
height: 30px;
left: 50%;
margin: 0 auto;
width: 30px;
&:after {
border: 5px solid $green;
border-radius: 50%;
content: '';
left: 10px;
position: absolute;
top: 16px;
}
}
}
@keyframes load {
100% { transform: rotate(360deg); }
}
</style>
</style>

View File

@@ -1,95 +0,0 @@
<template>
<div class="action">
<a class="action-link" :class="{'active': active}" @click="$emit('click')">
<svg class="action-icon">
<use v-if="active && iconRefActive" :xlink:href="iconRefActive"></use>
<use v-else :xlink:href="iconRef"></use>
</svg>
<span class="action-text">{{ active && textActive ? textActive : text }}</span>
</a>
</div>
</template>
<script>
export default {
props: {
iconRef: {
type: String,
required: true
},
iconRefActive: {
type: String,
required: false
},
active: {
type: Boolean,
default: false,
},
text: {
type: String,
required: true
},
textActive: {
type: String,
required: false
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/loading-placeholder";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.action {
&-link {
display: flex;
align-items: center;
text-decoration: none;
text-transform: uppercase;
color: rgba($c-dark, 0.5);
transition: color 0.5s ease;
font-size: 11px;
padding: 5px 0;
border-bottom: 1px solid rgba($c-dark, 0.05);
&:hover {
color: rgba($c-dark, 0.75);
}
&.active {
color: $c-dark;
}
&.pending {
color: #f8bd2d;
}
}
&-icon {
width: 18px;
height: 18px;
margin: 0 10px 0 0;
fill: rgba($c-dark, 0.5);
transition: fill 0.5s ease, transform 0.5s ease;
&.waiting {
transform: scale(0.8, 0.8);
}
&.pending {
fill: #f8bd2d;
}
}
&-link:hover &-icon {
fill: rgba($c-dark, 0.75);
cursor: pointer;
}
&-link.active &-icon {
fill: $c-green;
}
&-text {
display: block;
padding-top: 2px;
cursor: pointer;
margin:4.4px;
margin-left: -3px;
}
}
</style>

View File

@@ -17,29 +17,26 @@
align-items: center;
&--icon{
border: 2px solid rgba($c-dark, 0.9);
border: 2px solid $text-color-70;
border-radius: 50%;
display: block;
height: 40px;
// left: 50%;
// margin: -1.5em;
position: absolute;
width: 40px;
&-spinner {
// border: 2px solid transparent;
display: block;
display: block;
animation: load 1s linear infinite;
height: 35px;
width: 35px;
&:after {
border: 7px solid rgba($c-green, 0.9);
border-radius: 50%;
content: '';
left: 8px;
position: absolute;
top: 22px;
}
height: 35px;
width: 35px;
&:after {
border: 7px solid $green-90;
border-radius: 50%;
content: '';
left: 8px;
position: absolute;
top: 22px;
}
}
}
@keyframes load {

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,11 +9,14 @@
export default {
name: 'seasonedButton',
props: {
active: Boolean
active: {
type: Boolean,
default: false,
required: false
}
},
methods: {
emit() {
console.log('emitted')
this.$emit('click')
}
}
@@ -24,38 +27,39 @@ export default {
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.button{
button {
display: inline-block;
border: 1px solid $c-dark;
text-transform: uppercase;
background: $c-dark;
font-weight: 300;
border: 1px solid $text-color;
font-size: 11px;
line-height: 2;
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;
color: $text-color;
background: $background-color-secondary;
cursor: pointer;
color: $c-dark;
background: transparent;
outline: none;
transition: background 0.5s ease, color 0.5s ease;
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;
}
&:active, &:hover{
background: $c-dark;
color: $c-white;
&:focus, &:active, &.active {
background: $text-color;
color: $background-color;
}
body:not(.touch) &:hover, &:focus{
background: $c-dark;
color: $c-white;
}
&.active {
@extend .button;
background: $c-dark;
color: $c-white;
@media (hover: hover) {
&:hover {
background: $text-color;
color: $background-color;
}
}
}
</style>
</style>

View File

@@ -1,27 +1,37 @@
<template>
<div class="group" :class="{ completed: value.length > 0 }">
<div class="group" :class="{ completed: value }">
<svg class="group__input-icon"><use v-bind="{'xlink:href':'#icon' + icon}"></use></svg>
<input class="group__input" :type="tempType || type" ref="plex_username"
v-model="value" :placeholder="text" @input="handleInput" />
<input class="group__input" :type="tempType || type" @input="handleInput" v-model="inputValue"
:placeholder="placeholder" @keyup.enter="submit" />
<i v-if="value.length > 0 && type === 'password'" @click="toggleShowPassword" class="group__input-show noselect">show</i>
<i v-if="value && type === 'password'" @click="toggleShowPassword" class="group__input-show noselect">show</i>
</div>
</template>
<script>
export default {
props: {
text: { type: String },
placeholder: { type: String },
icon: { type: String },
type: { type: String }
type: { type: String, default: 'text' },
value: { type: String, default: undefined }
},
data() {
return { value: '', tempType: undefined }
return {
inputValue: this.value || undefined,
tempType: undefined
}
},
methods: {
handleInput(value) {
console.log('this.value', this.value)
this.$emit('inputValue', this.value)
submit(event) {
this.$emit('enter')
},
handleInput(event) {
if (this.value !== undefined) {
this.$emit('update:value', this.inputValue)
} else {
this.$emit('change', this.inputValue, event)
}
},
toggleShowPassword() {
if (this.tempType === 'text') {
@@ -40,42 +50,45 @@ export default {
.group{
display: flex;
margin-bottom: 1rem;
width: 100%;
&:hover, &:focus {
.group__input {
border-color: $c-dark;
border-color: $text-color;
&-icon {
fill: $c-dark;
fill: $text-color;
}
}
}
&.completed {
.group__input {
border-color: $c-dark;
border-color: $text-color;
&-icon {
fill: $c-dark;
fill: $text-color;
}
}
}
&__input {
width: 75%;
width: 100%;
max-width: 35rem;
padding: 10px 10px 10px 45px;
// padding: 15px 10px 15px 45px;
outline: none;
background-color: $c-white;
color: $c-dark;
background-color: $background-color-secondary;
color: $text-color;
font-weight: 100;
font-size: 1.2rem;
border: 1px solid rgba($c-dark, 0.5);
margin-left: -2.2rem;
// margin-bottom: 1rem;
border: 1px solid $text-color-50;
margin: 0;
margin-left: -2.2rem !important;
z-index: 3;
transition: border-color .5s ease;
transition: color .5s ease, background-color .5s ease, border .5s ease;
border-radius: 0;
-webkit-appearance: none;
&-show {
position: relative;
@@ -85,14 +98,14 @@ export default {
height: 100%;
font-size: 0.9rem;
cursor: pointer;
color: rgba($c-dark, 0.5);
color: $text-color-50;
}
}
&__input-icon {
width: 24px;
height: 24px;
fill: rgba($c-dark, 0.5);
fill: $text-color-50;
transition: fill 0.5s ease;
pointer-events: none;
margin-top: 10px;

View File

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

View File

@@ -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

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

View File

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

View File

@@ -2,6 +2,7 @@ import Vue from 'vue'
import VueRouter from 'vue-router'
import axios from 'axios'
import router from './routes'
import store from './store'
import Toast from './plugins/Toast'
import DataTablee from 'vue-data-tablee'
@@ -16,9 +17,12 @@ Vue.use(Toast)
Vue.use(DataTablee)
Vue.use(VModal, { dialog: true })
store.dispatch('darkmodeModule/findAndSetDarkmodeSupported')
new Vue({
el: '#app',
router,
store,
components: { App },
template: '<App />'
})

View File

@@ -0,0 +1,23 @@
export default {
namespaced: true,
state: {
darkmodeSupported: undefined,
userChoice: undefined
},
getters: {
darkmodeSupported: (state) => {
return state.darkmodeSupported
}
},
mutations: {
SET_DARKMODE_SUPPORT: (state, browserSupported) => {
state.darkmodeSupported = browserSupported
}
},
actions: {
findAndSetDarkmodeSupported({ commit }) {
const browserSupported = window.matchMedia('(prefers-color-scheme)').media !== 'not all'
commit('SET_DARKMODE_SUPPORT', browserSupported)
}
}
}

View File

@@ -0,0 +1,41 @@
const capitalize = (string) => {
return string.includes(' ') ?
string.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).replace('_', ' ')).join(' ')
: string.charAt(0).toUpperCase() + string.slice(1)
}
const setDocumentTitle = (state) => {
document.title = `${state.emoji} ${state.titlePrefix} | ${capitalize(state.title)}`
}
export default {
namespaced: true,
state: {
emoji: '',
titlePrefix: 'seasoned',
title: undefined
},
getters: {
title: (state) => {
return state.title
}
},
mutations: {
SET_EMOJI: (state, emoji) => {
state.emoji = emoji
setDocumentTitle(state)
},
SET_TITLE: (state, title) => {
state.title = title
setDocumentTitle(state)
}
},
actions: {
updateEmoji({ commit }, emoji) {
commit('SET_EMOJI', emoji)
},
updateTitle({ commit }, title) {
commit('SET_TITLE', title)
}
}
}

View File

@@ -0,0 +1,40 @@
export default {
namespaced: true,
state: {
results: [],
resultCount: null
},
getters: {
results: (state) => {
return state.results
},
resultCount: (state) => {
return state.resultCount
}
},
mutations: {
SET_RESULTS: (state, results) => {
state.results = results;
},
SET_RESULT_COUNT: (state, count) => {
state.resultCount = count;
},
RESET: (state) => {
state.results = []
state.resultCount = null
}
},
actions: {
setResults({ commit }, results) {
commit('SET_RESULTS', results)
},
setResultCount({ commit }, count) {
commit('SET_RESULT_COUNT', count)
},
reset({ commit }) {
commit('RESET')
}
}
}

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

@@ -1,5 +1,6 @@
import Vue from 'vue'
import VueRouter from 'vue-router';
import store from '@/store'
Vue.use(VueRouter)
@@ -10,27 +11,34 @@ 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)
},
{
name: 'list',
path: '/list/:name',
component: (resolve) => require(['./components/MoviesList.vue'], resolve)
component: (resolve) => require(['./components/ListPage.vue'], resolve)
},
{
name: 'request',
path: '/request/all',
components: {
'request-router-view': require('./components/MoviesList.vue')
'request-router-view': require('./components/ListPage.vue')
}
},
{
name: 'search',
path: '/search',
component: (resolve) => require(['./components/MoviesList.vue'], resolve)
component: (resolve) => require(['./components/Search.vue'], resolve)
},
{
name: 'register',
@@ -38,16 +46,16 @@ let routes = [
component: (resolve) => require(['./components/Register.vue'], resolve)
},
{
name: 'signin',
path: '/signin',
component: (resolve) => require(['./components/Signin.vue'], resolve)
name: 'settings',
path: '/settings',
meta: { requiresAuth: true },
component: (resolve) => require(['./components/Settings.vue'], resolve)
},
{
name: 'settings',
path: '/profile/settings',
components: {
'search-router-view': require('./components/Settings.vue')
}
name: 'signin',
path: '/signin',
alias: '/login',
component: (resolve) => require(['./components/Signin.vue'], resolve)
},
// {
// name: 'user-requests',
@@ -61,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: '/'
@@ -72,18 +91,27 @@ let routes = [
];
const router = new VueRouter({
mode: 'hash',
mode: 'history',
base: '/',
routes,
linkActiveClass: 'is-active'
});
router.beforeEach((to, from, next) => {
store.dispatch('documentTitle/updateTitle', to.name)
// Toggle mobile nav
if(document.querySelector('.nav__hamburger--active')){
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

@@ -2,21 +2,18 @@
@import "./src/scss/media-queries";
.filter {
// margin: 10px 10px 20px;
margin: 1rem;
padding: 0;
overflow: auto;
list-style: none;
border: 1px solid;
border-radius: 2px;
// overflow: hidden;
display: flex;
transition: color .2s ease;
// justify-content: space-evenly;
&-item {
padding: 6px 15px;
background-color: $c-white;
background-color: $background-color-secondary;
transition: color 0.2s ease;
font-size: 13px;
font-weight: 200;
@@ -24,16 +21,17 @@
text-align: center;
width: 100%;
white-space:nowrap;
// overflow: hidden;
&:nth-child(n+2) {
border-left: solid 1px;
}
&.active, &:hover {
border-color: transparent;
background-color: #091c24;
color: $c-green;
background-color: $teal;
color: $green;
cursor: pointer;
}
@include tablet-min {
font-size: 16px;
}

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

@@ -1,43 +0,0 @@
// TODO move all this to a plugin or something
.message-enter-active {
transition: all .3s ease;
}
.message-fade-leave-active {
transition: all .8s cubic-bezier(1.0, 0.5, 0.8, 1.0);
}
.message-fade-enter, .message-fade-leave-to {
opacity: 0;
}
.message{
width: 75%;
max-width: 35rem;
margin: 0 auto;
margin-bottom: 1rem;
padding: 12px 15px 12px 15px;
position: relative;
&-text{
font-weight: 300;
}
&-dismiss{
position: absolute;
font-size: 17px;
font-weight: 100;
top: 0;
right: 0;
margin-top: 2px;
margin-right: 5px;
cursor: pointer;
}
}
.message-warning{
background-color: #f2dede;
border: 1px solid #b75b91;
color: #b75b91;
}
.message-success{
background-color: #dff0d9;
border: 1px solid #3e7549;
color: #3e7549;
}

View File

@@ -1,12 +1,127 @@
// Colors
$c-green: #01d277;
$c-dark: #081c24;
$c-white: #ffffff;
$c-light: #f8f8f8;
$c-green-light: #dff0d9;
$c-green-dark: #3e7549;
$c-red-light: #f2dede;
$c-red-dark: #b75b91;
// @import "./media-queries";
@import "./src/scss/media-queries";
$header-size: 75px;
$header-size-mobile: 50px;
:root {
color-scheme: light;
--text-color: #081c24;
--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-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, 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);
--color-warning: rgba(241, 188, 53, 0.7);
--color-warning-highlight: #f1bc35;
--color-success: rgba(0, 100, 66, 0.8);
--color-success-text: #fff;
--color-success-highlight: rgb(0, 100, 66);
--color-error: rgba(220, 48, 35, 0.8);
--color-error-highlight: #dc3023;
--header-size: 75px;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: light dark;
--text-color: #fff;
--text-color-70: rgba(255, 255, 255, 0.7);
--text-color-50: rgba(255, 255, 255, 0.5);
--text-color-5: rgba(255, 255, 255, 0.05);
--text-color-secondary: orange;
--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: calc(50px + 1.5rem);
}
}
$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);
$white: #fff;
$white-80: rgba(255, 255, 255, 0.8);
$text-color: var(--text-color) !default;
$text-color-70: var(--text-color-70) !default;
$text-color-50: var(--text-color-50) !default;
$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;
$background-dark-85: rgba($dark, 0.85) !default;
$background-nav-logo: var(--background-nav-logo) !default;
$color-warning: var(--color-warning) !default;
$color-warning-highlight: var(--color-warning-highlight) !default;
$color-success: var(--color-success) !default;
$color-success-highlight: var(--color-success-highlight) !default;
$color-error: var(--color-error) !default;
$color-error-highlight: var(--color-error-highlight) !default;
.halloween {
--text-color: #6a318c;
--text-color-secondary: #fb5a33;
--background-color: #80c350;
--background-color-secondary: #ff9234;
}
.dark {
--text-color: #fff;
--text-color-70: rgba(255, 255, 255, 0.7);
--text-color-50: rgba(255, 255, 255, 0.5);
--text-color-5: rgba(255, 255, 255, 0.05);
--text-color-secondary: orange;
--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 {
--text-color: #081c24;
--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-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);
}

20
src/store.js Normal file
View File

@@ -0,0 +1,20 @@
import Vue from 'vue'
import Vuex from 'vuex'
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: {
darkmodeModule,
documentTitle,
torrentModule,
userModule
}
})
export default store

View File

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

View File

@@ -12,13 +12,20 @@ module.exports = {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {
'scss': 'vue-style-loader!css-loader!sass-loader',
'sass': 'vue-style-loader!css-loader!sass-loader?indentedSyntax'
use: [
{
loader: 'vue-loader',
options: {
loaders: {
'scss': 'vue-style-loader!css-loader!sass-loader',
'sass': 'vue-style-loader!css-loader!sass-loader?indentedSyntax'
}
}
},
{
loader: 'vue-svg-inline-loader'
}
}
]
},
{
test: /\.js$/,
@@ -39,6 +46,7 @@ module.exports = {
]
},
resolve: {
extensions: ['.js', '.vue', '.json', 'scss'],
alias: {
'vue$': 'vue/dist/vue.common.js',
'@': path.resolve(__dirname, './src'),

3607
yarn.lock

File diff suppressed because it is too large Load Diff