Compare commits

..

1 Commits

332 changed files with 16813 additions and 24936 deletions

View File

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

View File

@@ -1,3 +0,0 @@
SEASONED_API=http://localhost:31459
ELASTIC_URL=http://elastic.local:9200/tmdb-movies-shows
ELASTIC_API_KEY=

2
.gitignore vendored
View File

@@ -1,10 +1,8 @@
# config file - copy config.json.example
src/config.json
.env
# Build directory
dist/
lib/
# Node packages
node_modules/

8
.htaccess Normal file
View File

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

View File

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

View File

@@ -1,31 +0,0 @@
{
# Disable automatic HTTPS
auto_https off
}
:8080 {
root * {$DIST_PATH:/usr/share/caddy}
file_server
encode gzip zstd
try_files {path} {path}/ /index.html
# Cache favicons aggressively
@favicons path /favicons/*
header @favicons Cache-Control "public, max-age=31536000, immutable"
# Cache static assets based on MIME type
@static {
header Content-Type application/javascript*
header Content-Type text/css*
header Content-Type image/*
header Content-Type font/*
header Content-Type application/font-*
header Content-Type application/woff*
header Content-Type application/json*
}
header @static Cache-Control "public, max-age=2592000, immutable"
}

View File

@@ -1,38 +0,0 @@
FROM node:24.13.1 AS build
# Set the working directory for the build stage
WORKDIR /app
# Install dependencies
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Copy source files that the build depends on
COPY index.html .
COPY public/ public/
COPY src/ src/
COPY tsconfig.json vite.config.ts ./
ARG SEASONED_API=http://localhost:31459
ENV VITE_SEASONED_API=$SEASONED_API
ARG ELASTIC_URL=http://elastic.local:9200/tmdb-movies-shows
ENV VITE_ELASTIC_URL=$ELASTIC_URL
ARG ELASTIC_API_KEY=
ENV VITE_ELASTIC_API_KEY=$ELASTIC_API_KEY
RUN yarn build
FROM caddy:2.11-alpine
COPY Caddyfile /etc/caddy/Caddyfile
# Copy static files
COPY public /usr/share/caddy
# Copy the static build from the previous stage
COPY --from=build /app/dist /usr/share/caddy
EXPOSE 8080
LABEL org.opencontainers.image.source https://github.com/kevinmidboe/seasoned

View File

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

350
docs/api/assets/anchor.js Normal file
View File

@@ -0,0 +1,350 @@
/*!
* 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

@@ -0,0 +1,12 @@
.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;
}

544
docs/api/assets/bass.css Normal file
View File

@@ -0,0 +1,544 @@
/*! 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 }

Binary file not shown.

Binary file not shown.

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,23 @@
@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');
}

123
docs/api/assets/github.css Normal file
View File

@@ -0,0 +1,123 @@
/*
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;
}

168
docs/api/assets/site.js Normal file
View File

@@ -0,0 +1,168 @@
/* 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);

15
docs/api/assets/split.css Normal file
View File

@@ -0,0 +1,15 @@
.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;
}

586
docs/api/assets/split.js Normal file
View File

@@ -0,0 +1,586 @@
/*! 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;
});

140
docs/api/assets/style.css Normal file
View File

@@ -0,0 +1,140 @@
.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;
}

770
docs/api/index.html Normal file
View File

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

View File

@@ -1,66 +0,0 @@
import path from "node:path";
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import { defineConfig } from "eslint/config";
import { configs, plugins } from "eslint-config-airbnb-extended";
import { rules as prettierConfigRules } from "eslint-config-prettier";
import prettierPlugin from "eslint-plugin-prettier";
const CUSTOM_RULES = {
"vue/no-v-model-argument": "off",
"no-underscore-dangle": "off",
"vue/multi-word-component-names": "off"
};
const gitignorePath = path.resolve(".", ".gitignore");
// ESLint recommended config
const jsConfig = defineConfig([
{
name: "js/config",
...js.configs.recommended
},
plugins.stylistic,
plugins.importX,
...configs.base.recommended // Airbnb base recommended config
]);
// Node & Airbnb recommended config
const nodeConfig = defineConfig([plugins.node, ...configs.node.recommended]);
// Typescript & Airbnb base TS config
const typescriptConfig = defineConfig([
plugins.typescriptEslint,
...configs.base.typescript
// rules.typescript.typescriptEslintStrict
]);
// Prettier config
const prettierConfig = defineConfig([
{
name: "prettier/plugin/config",
plugins: {
prettier: prettierPlugin
}
},
{
name: "prettier/config",
rules: {
...prettierConfigRules,
"prettier/prettier": "error"
}
}
]);
export default defineConfig([
// Ignore files and folders listed in .gitignore
includeIgnoreFile(gitignorePath),
...jsConfig,
...nodeConfig,
...typescriptConfig,
...prettierConfig,
{
rules: CUSTOM_RULES
}
]);

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 889 B

After

Width:  |  Height:  |  Size: 889 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,34 +1,42 @@
{
"name": "seasoned-request",
"description": "seasoned request app",
"version": "1.22.17",
"version": "1.0.0",
"author": "Kevin Midboe",
"private": true,
"scripts": {
"dev": "NODE_ENV=development vite",
"build": "yarn vite build",
"lint": "eslint src; prettier -c src",
"clean": "rm -rf dist/ yarn-*.log 2>/dev/null",
"docs": "documentation build src/api.ts -f html -o docs/api && documentation build src/api.ts -f md -o docs/api.md"
"dev": "cross-env NODE_ENV=development webpack-dev-server --hot",
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules",
"start": "node server.js",
"docs": "documentation build src/api.js -f html -o docs/api && documentation build src/api.js -f md -o docs/api.md"
},
"dependencies": {
"chart.js": "3.9.1",
"vue": "3.5.28",
"vue-router": "5.0.3",
"vuex": "4.1.0"
"axios": "^0.15.3",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"connect-history-api-fallback": "^1.3.0",
"express": "^4.16.1",
"vue": "^2.5.2",
"vue-axios": "^1.2.2",
"vue-data-tablee": "^0.12.1",
"vue-js-modal": "^1.3.16",
"vue-router": "^3.0.1",
"vuex": "^3.1.0"
},
"devDependencies": {
"@eslint/compat": "^2.0.2",
"@eslint/js": "^10.0.1",
"@types/node": "^25.3.0",
"@vitejs/plugin-vue": "^5.2.1",
"eslint": "^10.0.1",
"eslint-config-airbnb-extended": "^3.0.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"prettier": "^3.8.1",
"sass": "1.54.3",
"typescript": "5.9.3",
"vite": "^6.0.3"
"@babel/core": "^7.4.5",
"@babel/plugin-transform-runtime": "^7.4.4",
"@babel/preset-env": "^7.4.5",
"@babel/runtime": "^7.4.5",
"babel-loader": "^8.0.6",
"cross-env": "^3.0.0",
"css-loader": "^0.25.0",
"documentation": "^11.0.0",
"file-loader": "^0.9.0",
"node-sass": "^4.5.0",
"sass-loader": "^5.0.1",
"vue-loader": "^10.0.0",
"vue-template-compiler": "2.6.10",
"webpack": "^2.2.0",
"webpack-dev-server": "^2.2.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 641 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.3 KiB

23
server.js Normal file
View File

@@ -0,0 +1,23 @@
var express = require('express');
var path = require('path');
const compression = require('compression')
var history = require('connect-history-api-fallback');
app = express();
app.use(compression())
app.use('/dist', express.static(path.join(__dirname + "/dist")));
app.use('/dist', express.static(path.join(__dirname + "/dist/")));
app.use('/favicons', express.static(path.join(__dirname + "/favicons")));
app.use(history({
index: '/'
}));
var port = process.env.PORT || 5000;
app.get('/', function(req, res) {
res.sendFile(path.join(__dirname + '/index.html'));
});
app.listen(port);

View File

@@ -1,73 +1,199 @@
<template>
<div id="content">
<!-- Header and hamburger navigation -->
<NavigationHeader class="header" />
<div id="app">
<div class="navigation-icons-gutter desktop-only">
<NavigationIcons />
</div>
<!-- Header and hamburger navigation -->
<navigation></navigation>
<!-- Header with search field -->
<header class="header">
<search-input v-model="query"></search-input>
</header>
<!-- Movie popup that will show above existing rendered content -->
<movie-popup v-if="moviePopupIsVisible" :id="popupID" :type="popupType"></movie-popup>
<!-- Display the component assigned to the given route (default: home) -->
<router-view :key="router.currentRoute.value.path" class="content" />
<router-view class="content"></router-view>
<!-- Popup that will show above existing rendered content -->
<popup />
<!-- Command Palette for quick navigation -->
<command-palette />
</div>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import NavigationHeader from "@/components/header/NavigationHeader.vue";
import NavigationIcons from "@/components/header/NavigationIcons.vue";
import Popup from "@/components/Popup.vue";
import CommandPalette from "@/components/ui/CommandPalette.vue";
<script>
import Vue from 'vue'
import Navigation from '@/components/Navigation.vue'
import MoviePopup from '@/components/MoviePopup.vue'
import SearchInput from '@/components/SearchInput.vue'
const router = useRouter();
</script>
<style lang="scss">
@import "scss/main";
@import "scss/media-queries";
#content {
display: grid;
grid-template-rows: var(--header-size);
grid-template-columns: var(--header-size) 100%;
@include mobile {
grid-template-columns: 1fr;
export default {
name: 'app',
components: {
Navigation,
MoviePopup,
SearchInput
},
data() {
return {
query: '',
moviePopupIsVisible: false,
popupID: 0,
popupType: 'movie'
}
.header {
position: fixed;
top: 0;
width: 100%;
z-index: 15;
}
.navigation-icons-gutter {
position: fixed;
height: 100vh;
margin: 0;
top: var(--header-size);
width: var(--header-size);
background-color: var(--background-color-secondary);
}
.content {
display: grid;
grid-column: 2 / 3;
width: calc(100% - var(--header-size));
grid-row: 2;
z-index: 5;
@include mobile {
grid-column: 1 / 3;
width: 100%;
},
created(){
let that = this
Vue.prototype.$popup = {
get isOpen() {
return that.moviePopupIsVisible
},
open: (id, type) => {
this.popupID = id || this.popupID
this.popupType = type || this.popupType
this.moviePopupIsVisible = true
console.log('opened')
},
close: () => {
this.moviePopupIsVisible = false
console.log('closed')
}
}
console.log('MoviePopup registered at this.$popup and has state: ', this.$popup.isOpen)
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/media-queries";
@import "./src/scss/variables";
.content {
@include tablet-min{
width: calc(100% - 95px);
padding-top: $header-size;
margin-left: 95px;
position: relative;
}
}
</style>
<style lang="scss">
@import "./src/scss/main";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@font-face {
font-family: 'Roboto';
}
*{
box-sizing: border-box;
}
html, body{
height: 100%;
}
body{
font-family: 'Roboto', sans-serif;
line-height: 1.6;
background: $c-light;
color: $c-dark;
&.hidden{
overflow: hidden;
}
}
input, textarea, button{
font-family: 'Roboto', sans-serif;
}
figure{
padding: 0;
margin: 0;
}
img{
display: block;
// max-width: 100%;
height: auto;
}
.wrapper{
position: relative;
}
.header{
position: fixed;
background: $c-white;
z-index: 15;
display: flex;
flex-direction: column;
@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 {
transition-property: opacity;
transition-duration: 0.25s;
}
.fade-enter-active {
transition-delay: 0.25s;
}
.fade-enter, .fade-leave-active {
opacity: 0
}
</style>

255
src/api.js Normal file
View File

@@ -0,0 +1,255 @@
import axios from 'axios'
import storage from '@/storage.js'
import config from '@/config.json'
import path from 'path'
const SEASONED_URL = config.SEASONED_URL
const ELASTIC_URL = config.ELASTIC_URL
const ELASTIC_INDEX = config.ELASTIC_INDEX
// TODO
// - Move autorization token and errors here?
// - - - TMDB - - -
/**
* Fetches tmdb movie by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getMovie = (id, credits=false) => {
const url = new URL('v2/movie', 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 movie: ${id}`); throw error })
}
/**
* Fetches tmdb show by id. Can optionally include cast credits in result object.
* @param {number} id
* @param {boolean} [credits=false] Include credits
* @returns {object} Tmdb response
*/
const getShow = (id, credits=false) => {
const url = new URL('v2/show', 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 })
}
/**
* Fetches tmdb list by path.
* @param {string} listPath Path of list
* @param {number} [page=1]
* @returns {object} Tmdb list response
*/
const getTmdbListByPath = (listPath, page=1) => {
const url = new URL(listPath, 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 })
}
/**
* Fetches tmdb movies and shows by query.
* @param {string} query
* @param {number} [page=1]
* @returns {object} Tmdb response
*/
const searchTmdb = (query, page=1) => {
const url = new URL('v2/search', SEASONED_URL)
url.searchParams.append('query', query)
url.searchParams.append('page', page)
return axios.get(url.href)
.catch(error => { console.error(`api error searching: ${query}, page: ${page}`); throw error })
}
// - - - Torrents - - -
/**
* Search for torrents by query
* @param {string} query
* @param {boolean} credits Include credits
* @returns {object} Torrent response
*/
const searchTorrents = (query, authorization_token) => {
const url = new URL('v1/pirate/search', SEASONED_URL)
url.searchParams.append('query', query)
const headers = { authorization: storage.token }
return axios.get(url.href, { headers: headers })
.catch(error => { console.error(`api error searching torrents: ${query}`); throw error })
}
/**
* Add magnet to download queue.
* @param {string} magnet Magnet link
* @param {boolean} name Name of torrent
* @param {boolean} tmdb_id
* @returns {object} Success/Failure response
*/
const addMagnet = (magnet, name, tmdb_id) => {
const url = new URL('v1/pirate/add', SEASONED_URL)
const body = {
magnet: magnet,
name: name,
tmdb_id: tmdb_id
}
const headers = { authorization: storage.token }
return axios.post(url.href, body, { headers: headers })
.catch(error => { console.error(`api error adding magnet: ${name}`); throw error })
}
// - - - Plex/Request - - -
/**
* Request a movie or show from id. If authorization token is included the user will be linked
* to the requested item.
* @param {number} id Movie or show id
* @param {string} type Movie or show type
* @param {string} [authorization_token] To identify the requesting user
* @returns {object} Success/Failure response
*/
const request = (id, type, authorization_token=undefined) => {
const url = new URL('v2/request', SEASONED_URL)
// url.pathname = path.join(url.pathname, id.toString())
// url.searchParams.append('type', type)
const headers = {
'Authorization': authorization_token,
'Content-Type': 'application/json'
}
const body = {
id: id,
type: type
}
return fetch(url.href, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
})
.then(resp => resp.json())
.catch(error => { console.error(`api error requesting: ${id}, type: ${type}`); throw error })
}
/**
* Check request status by tmdb id and type
* @param {number} tmdb id
* @param {string} type
* @returns {object} Success/Failure response
*/
const getRequestStatus = (id, type, authorization_token=undefined) => {
const url = new URL('v2/request', SEASONED_URL)
url.pathname = path.join(url.pathname, id.toString())
url.searchParams.append('type', type)
return fetch(url.href)
.then(resp => {
const status = resp.status;
if (status === 200) { return true }
else if (status === 404) { return false }
else {
console.error(`api error getting request status for id ${id} and type ${type}`)
}
})
.catch(err => Promise.reject(err))
}
// - - - 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 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'
}
return axios.post(url.href, { headers: headers })
.catch(error => { console.error(`api error authentication plex: ${username}`); throw error })
}
// - - - Random emoji - - -
const getEmoji = () => {
const url = path.join(SEASONED_URL, 'v1/emoji')
return axios.get(url)
.catch(error => { console.log('api error getting emoji'); throw error })
}
// - - - ELASTIC SEARCH - - -
// This elastic index contains titles mapped to ids. Lightning search
// used for autocomplete
/**
* Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and
* Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports
* @param {string} query
* @returns {object} List of movies and shows matching query
*/
const elasticSearchMoviesAndShows = (query) => {
const url = new URL(path.join(ELASTIC_INDEX, '/_search'), ELASTIC_URL)
const headers = {
'Content-Type': 'application/json'
}
const body = {
"sort" : [
{ "popularity" : {"order" : "desc"}},
"_score"
],
"query": {
"bool": {
"should": [{
"match_phrase_prefix": {
"original_name": query
}
},
{
"match_phrase_prefix": {
"original_title": query
}
}]
}
},
"size": 6
}
return fetch(url.href, {
method: 'POST',
headers: headers,
body: JSON.stringify(body)
})
.then(resp => resp.json())
.catch(error => { console.log(`api error searching elasticsearch: ${query}`); throw error })
}
export { getMovie, getShow, getTmdbListByPath, searchTmdb, searchTorrents, addMagnet, request, getRequestStatus, plexAuthenticate, getEmoji, elasticSearchMoviesAndShows }

View File

@@ -1,587 +0,0 @@
/* eslint-disable n/no-unsupported-features/node-builtins */
import {
IList,
IMediaCredits,
IPersonCredits,
MediaTypes
} from "./interfaces/IList";
import type {
IRequestStatusResponse,
IRequestSubmitResponse
} from "./interfaces/IRequestResponse";
const API_HOSTNAME = import.meta.env.VITE_SEASONED_API;
const ELASTIC_URL = import.meta.env.VITE_ELASTIC_URL;
const ELASTIC_API_KEY = import.meta.env.VITE_ELASTIC_API_KEY;
// - - - TMDB - - -
interface GetMediaOpts {
checkExistance: boolean;
credits: boolean;
releaseDates?: boolean;
}
const getMovie = async (id: number, opts: GetMediaOpts) => {
const url = new URL("/api/v2/movie", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
const { checkExistance, credits, releaseDates } = opts;
if (checkExistance) {
url.searchParams.append("check_existance", "true");
}
if (credits) {
url.searchParams.append("credits", "true");
}
if (releaseDates) {
url.searchParams.append("release_dates", "true");
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb show by id. Can optionally include cast credits in result object.
const getShow = async (id: number, opts: GetMediaOpts) => {
const url = new URL("/api/v2/show", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
const { checkExistance, credits, releaseDates } = opts;
if (checkExistance) {
url.searchParams.append("check_existance", "true");
}
if (credits) {
url.searchParams.append("credits", "true");
}
if (releaseDates) {
url.searchParams.append("release_dates", "true");
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting show: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb person by id. Can optionally include cast credits in result object.
const getPerson = async (id: number, credits = false) => {
const url = new URL("/api/v2/person", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
if (credits) {
url.searchParams.append("credits", "true");
}
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting person: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb movie credits by id.
const getMovieCredits = async (id: number): Promise<IMediaCredits> => {
const url = new URL("/api/v2/movie", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting movie: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb show credits by id.
const getShowCredits = async (id: number): Promise<IMediaCredits> => {
const url = new URL("/api/v2/show", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting show: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb person credits by id.
const getPersonCredits = async (id: number): Promise<IPersonCredits> => {
const url = new URL("/api/v2/person", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}/credits`;
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error getting person: ${id}`); // eslint-disable-line no-console
throw error;
});
};
// Fetches tmdb list by name.
const getTmdbMovieListByName = async (
name: string,
page = 1
): Promise<IList> => {
const url = new URL(`/api/v2/movie/${name}`, API_HOSTNAME);
url.searchParams.append("page", page.toString());
return fetch(url.href).then(resp => resp.json());
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
};
// Fetches requested items.
const getRequests = async (page = 1) => {
const url = new URL("/api/v2/request", API_HOSTNAME);
url.searchParams.append("page", page.toString());
return fetch(url.href).then(resp => resp.json());
// .catch(error => { console.error(`api error getting list: ${name}, page: ${page}`); throw error }) // eslint-disable-line no-console
};
const getUserRequests = async (page = 1) => {
const url = new URL("/api/v1/user/requests", API_HOSTNAME);
url.searchParams.append("page", page.toString());
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options).then(resp => resp.json());
};
// Fetches tmdb movies and shows by query.
const searchTmdb = async (
query: string,
page = 1,
adult = false,
mediaType = null
) => {
const url = new URL("/api/v2/search", API_HOSTNAME);
if (mediaType != null && ["movie", "show", "person"].includes(mediaType)) {
url.pathname += `/${mediaType}`;
}
url.searchParams.append("query", query);
url.searchParams.append("page", page.toString());
url.searchParams.append("adult", adult.toString());
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.error(`api error searching: ${query}, page: ${page}`); // eslint-disable-line no-console
throw error;
});
};
// - - - Torrents - - -
// Search for torrents by query
const searchTorrents = async (query: string) => {
const url = new URL("/api/v1/pirate/search", API_HOSTNAME);
url.searchParams.append("query", query);
const options: RequestInit = {
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error searching torrents: ${query}`); // eslint-disable-line no-console
throw error;
});
};
// Add magnet to download queue.
const addMagnet = async (
magnet: string,
name: string,
tmdbId: number | null
) => {
const url = new URL("/api/v1/pirate/add", API_HOSTNAME);
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
magnet,
name,
tmdb_id: tmdbId
})
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error adding magnet: ${name} ${error}`); // eslint-disable-line no-console
throw error;
});
};
// - - - Plex/Request - - -
// Request a movie or show from id. If authorization token is included the user will be linked
const request = async (
id: number,
type: MediaTypes.Movie | MediaTypes.Show
): Promise<IRequestSubmitResponse> => {
const url = new URL("/api/v2/request", API_HOSTNAME);
const options = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, type })
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error requesting: ${id}, type: ${type}`); // eslint-disable-line no-console
throw error;
});
};
// Check request status by tmdb id and type
const getRequestStatus = async (
id: number,
type = null
): Promise<IRequestStatusResponse> => {
const url = new URL("/api/v2/request", API_HOSTNAME);
url.pathname = `${url.pathname}/${id.toString()}`;
url.searchParams.append("type", type);
return fetch(url.href)
.then(resp => resp.json())
.catch(err => Promise.reject(err));
};
const watchLink = async (title: string, year: string) => {
const url = new URL("/api/v1/plex/watch-link", API_HOSTNAME);
url.searchParams.append("title", title);
url.searchParams.append("year", year);
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.then(response => response.link);
};
/*
const movieImages = id => {
const url = new URL(`v2/movie/${id}/images`, API_HOSTNAME);
return fetch(url.href).then(resp => resp.json());
};
*/
// - - - Seasoned user endpoints - - -
const register = async (username: string, password: string) => {
const url = new URL("/api/v1/user", API_HOSTNAME);
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password })
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
const errorMessage =
"Unexpected error occured before receiving response. Error:";
// eslint-disable-next-line no-console
console.error(errorMessage, error);
// TODO log to sentry the issue here
throw error;
});
};
const login = async (
username: string,
password: string,
throwError = false
) => {
const url = new URL("/api/v1/user/login", API_HOSTNAME);
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, password })
};
return fetch(url.href, options).then(resp => {
if (resp.status === 200) return resp.json();
if (throwError) return Promise.reject(resp.text().then(t => new Error(t)));
console.error("Error occured when trying to sign in.\nError:", resp); // eslint-disable-line no-console
return Promise.reject(resp);
});
};
const logout = async (throwError = false) => {
const url = new URL("/api/v1/user/logout", API_HOSTNAME);
const options: RequestInit = { method: "POST", credentials: "include" };
return fetch(url.href, options).then(resp => {
if (resp.status === 200) return resp.json();
if (throwError) return Promise.reject(resp.text().then(t => new Error(t)));
console.error("Error occured when trying to log out.\nError:", resp); // eslint-disable-line no-console
return Promise.reject(resp);
});
};
const getSettings = async () => {
const url = new URL("/api/v1/user/settings", API_HOSTNAME);
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log("api error getting user settings"); // eslint-disable-line no-console
throw error;
});
};
const updateSettings = async (settings: any) => {
const url = new URL("/api/v1/user/settings", API_HOSTNAME);
const options: RequestInit = {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(settings)
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log("api error updating user settings"); // eslint-disable-line no-console
throw error;
});
};
// - - - Authenticate with plex - - -
const linkPlexAccount = async (authToken: string) => {
const url = new URL("/api/v1/user/link_plex", API_HOSTNAME);
const body = { authToken };
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body)
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error("api error linking plex account"); // eslint-disable-line no-console
throw error;
});
};
const unlinkPlexAccount = async () => {
const url = new URL("/api/v1/user/unlink_plex", API_HOSTNAME);
const options: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error unlinking your plex account`); // eslint-disable-line no-console
throw error;
});
};
const plexRecentlyAddedInLibrary = async (id: number) => {
const url = new URL(`/api/v2/plex/recently_added/${id}`, API_HOSTNAME);
const options: RequestInit = {
credentials: "include"
};
return fetch(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.error(`api error fetch plex recently added`); // eslint-disable-line no-console
throw error;
});
};
// - - - User graphs - - -
const fetchGraphData = async (
urlPath: string,
days: number,
chartType: string
) => {
const url = new URL(`/api/v1/user/${urlPath}`, API_HOSTNAME);
url.searchParams.append("days", String(days));
url.searchParams.append("y_axis", chartType);
const options: RequestInit = {
headers: { "Content-Type": "application/json" },
credentials: "include"
};
return fetch(url.href, options).then(resp => {
if (!resp.ok) {
console.log("DAMN WE FAILED!", resp); // eslint-disable-line no-console
throw Error(resp.statusText);
}
return resp.json();
});
};
// - - - Random emoji - - -
const getEmoji = async () => {
const url = new URL("/api/v1/emoji", API_HOSTNAME);
return fetch(url.href)
.then(resp => resp.json())
.catch(error => {
console.log("api error getting emoji"); // eslint-disable-line no-console
throw error;
});
};
// - - - ELASTIC SEARCH - - -
// This elastic index contains titles mapped to ids. Lightning search
// used for autocomplete
interface TimeoutRequestInit extends RequestInit {
timeout: number;
}
async function fetchWithTimeout(url: string, options: TimeoutRequestInit) {
const { timeout = 2000 } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timer);
return response;
}
/**
* Search elastic indexes movies and shows by query. Doc includes Tmdb daily export of Movies and
* Tv Shows. See tmdb docs for more info: https://developers.themoviedb.org/3/getting-started/daily-file-exports
*/
const elasticSearchMoviesAndShows = async (query: string, count = 22) => {
const url = new URL(`${ELASTIC_URL}/_search`);
const body = {
sort: [{ popularity: { order: "desc" } }, "_score"],
size: count,
query: {
multi_match: {
query,
fields: ["name", "original_title", "original_name"],
type: "phrase_prefix",
tie_breaker: 0.3
}
},
suggest: {
text: query,
"person-suggest": {
prefix: query,
completion: {
field: "name.completion",
fuzzy: {
fuzziness: "AUTO"
}
}
},
"movie-suggest": {
prefix: query,
completion: {
field: "original_title.completion",
fuzzy: {
fuzziness: "AUTO"
}
}
},
"show-suggest": {
prefix: query,
completion: {
field: "original_name.completion",
fuzzy: {
fuzziness: "AUTO"
}
}
}
}
};
const options = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `ApiKey ${ELASTIC_API_KEY}`
},
body: JSON.stringify(body),
timeout: 1000
};
return fetchWithTimeout(url.href, options)
.then(resp => resp.json())
.catch(error => {
console.log(`api error searching elasticsearch: ${query}`); // eslint-disable-line no-console
throw error;
});
};
export {
API_HOSTNAME,
getMovie,
getShow,
getPerson,
getMovieCredits,
getShowCredits,
getPersonCredits,
getTmdbMovieListByName,
searchTmdb,
getUserRequests,
getRequests,
searchTorrents,
addMagnet,
request,
getRequestStatus,
linkPlexAccount,
unlinkPlexAccount,
plexRecentlyAddedInLibrary,
register,
login,
logout,
getSettings,
updateSettings,
fetchGraphData,
watchLink,
getEmoji,
elasticSearchMoviesAndShows
};

View File

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

BIN
src/assets/no-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 275 KiB

View File

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 423 KiB

72
src/components/404.vue Normal file
View File

@@ -0,0 +1,72 @@
<template>
<section class="not-found">
<div class="not-found__content">
<h2 class="not-found__title">Page Not Found</h2>
</div>
</section>
</template>
<script>
import storage from '../storage.js'
export default {
created(){
document.title = 'Page Not Found' + storage.pageTitlePostfix;
}
}
</script>
<style lang="scss">
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.not-found{
width: 100%;
height: calc(100vh - 100px);
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%;
}
}
&__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

@@ -1,51 +0,0 @@
<template>
<div class="cast">
<ol class="persons">
<CastListItem
v-for="credit in cast"
:key="credit.id"
:credit-item="credit"
/>
</ol>
</div>
</template>
<script setup lang="ts">
import { defineProps } from "vue";
import CastListItem from "@/components/CastListItem.vue";
import type {
IMovie,
IShow,
IPerson,
ICast,
ICrew
} from "../interfaces/IList";
interface Props {
cast: Array<IMovie | IShow | IPerson | ICast | ICrew>;
}
defineProps<Props>();
</script>
<style lang="scss">
.cast {
position: relative;
top: 0;
left: 0;
ol {
overflow-x: scroll;
padding: 0;
list-style-type: none;
margin: 0;
display: flex;
scrollbar-width: none; /* for Firefox */
&::-webkit-scrollbar {
display: none; /* for Chrome, Safari, and Opera */
}
}
}
</style>

View File

@@ -1,187 +0,0 @@
<template>
<li class="cast-card">
<a
class="cast-card__link"
role="button"
tabindex="0"
:aria-label="ariaLabel"
@click="openCastItem"
@keydown.enter="openCastItem"
>
<div class="cast-card__image-wrapper">
<img
class="cast-card__image"
:src="pictureUrl"
:alt="imageAltText"
loading="lazy"
/>
</div>
<div class="cast-card__content">
<p class="cast-card__name">{{ creditItem.name || creditItem.title }}</p>
<p v-if="metaText" class="cast-card__meta">{{ metaText }}</p>
</div>
</a>
</li>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useStore } from "vuex";
import type { ICast, ICrew, IMovie, IShow } from "../interfaces/IList";
interface Props {
creditItem: ICast | ICrew | IMovie | IShow;
}
const props = defineProps<Props>();
const store = useStore();
const pictureUrl = computed(() => {
const baseUrl = "https://image.tmdb.org/t/p/w185";
if ("profile_path" in props.creditItem && props.creditItem.profile_path) {
return baseUrl + props.creditItem.profile_path;
}
if ("poster" in props.creditItem && props.creditItem.poster) {
return baseUrl + props.creditItem.poster;
}
return "/assets/no-image_small.svg";
});
const metaText = computed(() => {
if ("character" in props.creditItem && props.creditItem.character) {
return props.creditItem.character;
}
if ("job" in props.creditItem && props.creditItem.job) {
return props.creditItem.job;
}
if ("year" in props.creditItem && props.creditItem.year) {
return props.creditItem.year;
}
return "";
});
const imageAltText = computed(() => {
const name = props.creditItem.name || (props.creditItem as any).title || "";
if ("character" in props.creditItem) {
return `${name} as ${props.creditItem.character}`;
}
if ("job" in props.creditItem) {
return `${name}, ${props.creditItem.job}`;
}
return name ? `Poster for ${name}` : "No image available";
});
const ariaLabel = computed(() => {
const name = props.creditItem.name || (props.creditItem as any).title || "";
if ("character" in props.creditItem && props.creditItem.character) {
return `View ${name}, played ${props.creditItem.character}`;
}
if ("job" in props.creditItem && props.creditItem.job) {
return `View ${name}, ${props.creditItem.job}`;
}
return `View ${name}`;
});
function openCastItem() {
store.dispatch("popup/open", { ...props.creditItem });
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
.cast-card {
list-style: none;
margin: 0 10px 10px 0;
width: 150px;
flex-shrink: 0;
&:first-of-type {
margin-left: 0;
}
}
.cast-card__link {
display: flex;
flex-direction: column;
height: 100%;
text-decoration: none;
color: inherit;
cursor: pointer;
border-radius: 10px;
overflow: hidden;
background-color: var(
--highlight-secondary,
var(--background-color-secondary)
);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover,
&:focus {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
outline: none;
}
&:focus-visible {
outline: 2px solid var(--highlight-color);
outline-offset: 2px;
}
}
.cast-card__image-wrapper {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
overflow: hidden;
background: linear-gradient(
135deg,
var(--background-color) 0%,
var(--background-color-secondary) 100%
);
}
.cast-card__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cast-card__content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 60px;
}
.cast-card__name {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
line-height: 1.3;
color: var(--highlight-bg, var(--text-color));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.cast-card__meta {
margin: 0;
font-size: 0.85rem;
font-weight: 400;
line-height: 1.3;
color: var(--highlight-bg, var(--text-color-70));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -1,234 +0,0 @@
<template>
<div class="graph-wrapper">
<canvas ref="graphCanvas"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onBeforeUnmount } from "vue";
import {
Chart,
LineElement,
BarElement,
PointElement,
LineController,
BarController,
LinearScale,
CategoryScale,
Legend,
Title,
Tooltip,
Filler,
ChartType
} from "chart.js";
import type { BarOptions, ChartOptions } from "chart.js";
import type { Ref } from "vue";
import { convertSecondsToHumanReadable } from "../utils";
import { GraphTypes, GraphValueTypes } from "../interfaces/IGraph";
import type { IGraphDataset, IGraphData } from "../interfaces/IGraph";
Chart.register(
LineElement,
BarElement,
PointElement,
LineController,
BarController,
LinearScale,
CategoryScale,
Legend,
Title,
Tooltip,
Filler
);
interface Props {
name?: string;
data: IGraphData;
type: ChartType;
stacked: boolean;
datasetDescriptionSuffix: string;
tooltipDescriptionSuffix: string;
graphValueType?: GraphValueTypes;
}
const props = defineProps<Props>();
const graphCanvas: Ref<HTMLCanvasElement | null> = ref(null);
let graphInstance: Chart | null = null;
const graphTemplates = [
{
borderColor: "#6366F1",
backgroundColor: "rgba(99,102,241,0.12)"
},
{
borderColor: "#F59E0B",
backgroundColor: "rgba(245,158,11,0.12)"
},
{
borderColor: "#10B981",
backgroundColor: "rgba(16,185,129,0.12)"
}
];
onMounted(() => generateGraph());
watch(() => props.data, generateGraph, { deep: true });
onBeforeUnmount(() => {
if (graphInstance) graphInstance.destroy();
});
function removeEmptyDataset(dataset: IGraphDataset) {
return dataset;
return !dataset.data.every(point => point === 0);
}
function hydrateDataset(dataset: IGraphDataset, index: number) {
const base = graphTemplates[index % graphTemplates.length];
if (props.type === "bar") {
return {
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
data: dataset.data,
backgroundColor: base.borderColor,
inflateAmount: 0,
borderRadius: {
topLeft: 8,
topRight: 8,
bottomLeft: 8,
bottomRight: 8
},
borderSkipped: false,
borderWidth: 2,
borderColor: "transparent",
// Slight spacing between categories
barPercentage: 0.8,
categoryPercentage: 0.9
} as BarOptions;
}
// Line chart — subtle, minimal points
return {
label: `${dataset.name} ${props.datasetDescriptionSuffix}`,
data: dataset.data,
borderColor: base.borderColor,
backgroundColor: base.backgroundColor,
borderWidth: 2,
tension: 0.35,
fill: true,
pointRadius: 2,
pointHoverRadius: 5,
pointHitRadius: 12,
pointBackgroundColor: base.borderColor,
pointBorderColor: base.borderColor,
pointBorderWidth: 0
};
}
function generateGraph() {
if (!graphCanvas.value) return;
const datasets = props.data.series
.filter(removeEmptyDataset)
.map(hydrateDataset);
const chartData = {
labels: props.data.labels,
datasets
};
const options: ChartOptions = {
maintainAspectRatio: false,
responsive: true,
layout: {
padding: { top: 8 }
},
plugins: {
legend: {
display: true
},
tooltip: {
backgroundColor: "#111827",
bodyColor: "#e5e7eb",
padding: 12,
cornerRadius: 8,
displayColors: true,
callbacks: {
label: (tooltipItem: any) => {
const context = tooltipItem.dataset.label.split(" ")[0];
let type = GraphTypes.Plays;
let value = tooltipItem.raw;
if (props.graphValueType === String(GraphTypes.Duration)) {
value = convertSecondsToHumanReadable(value);
type = GraphTypes.Duration;
}
const text = `${context} ${type}`;
return `${text}: ${value}`;
}
}
}
},
scales: {
x: {
stacked: props.stacked,
grid: {
display: false,
drawBorder: false
},
ticks: {
color: "#9CA3AF",
font: { size: 11 }
}
},
y: {
stacked: props.stacked,
beginAtZero: true,
grid: {
color: "rgba(0,0,0,0.04)",
drawBorder: false
},
ticks: {
color: "#9CA3AF",
font: { size: 11 },
padding: 8,
callback: (value: number) => {
if (props.graphValueType === String(GraphTypes.Duration)) {
return convertSecondsToHumanReadable(value);
}
return value;
}
}
}
}
};
if (graphInstance) {
graphInstance.data = chartData;
graphInstance.update();
return;
}
graphInstance = new Chart(graphCanvas.value, {
type: props.type,
data: chartData,
options
});
}
</script>
<style scoped lang="scss">
.graph-wrapper {
position: relative;
width: 100%;
height: 100%;
min-height: 240px;
}
</style>

28
src/components/Home.vue Normal file
View File

@@ -0,0 +1,28 @@
<template>
<section>
<LandingBanner />
<movies-list v-for="item in homepageLists" :propList="item" :shortList="true"></movies-list>
</section>
</template>
<script>
import storage from '../storage.js'
import LandingBanner from '@/components/LandingBanner.vue'
import MoviesList from './MoviesList.vue'
export default {
name: 'home',
components: { LandingBanner, MoviesList },
data(){
return {
homepageLists: storage.homepageLists,
imageFile: 'dist/pulp-fiction.jpg'
}
},
created(){
document.title = 'TMDb';
storage.backTitle = document.title;
}
}
</script>

View File

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

View File

@@ -0,0 +1,530 @@
<template>
<div class="wrapper">
<div class="backdrop" :style="backdropStyle"></div>
<div class="title-container">
<h1> {{ title }} </h1>
<h2> ({{ movie.year }}) </h2>
</div>
<div class="opacity-overlay-bottom"></div>
<div class="info-container">
<div class="button">
<svg class="action-icon">
<use :xlink:href="'#iconSent'"></use>
</svg>
<span>Request movie</span>
</div>
<div class="button--grey">
<svg class="action-icon">
<use :xlink:href="'#icon_info'"></use>
</svg>
<span>See more info</span>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@import "./src/scss/variables";
$blue: rgb(0,122,255);
$blue-hover: rgb(10,132,255);
$grey: rgb(44,44,46);
$grey-hover: rgb(58,58,60);
.action-icon {
transform: translateY(-0.2px);
width: 19px;
height: 19px;
margin-right: 8px;
// fill: rgba($c-dark, 0.5);
fill: white;
transition: fill 0.5s ease, transform 0.5s ease;
&.waiting {
transform: scale(0.8, 0.8);
}
&.pending {
fill: #f8bd2d;
}
}
@mixin button {
width: 300px;
min-height: 50px;
border-radius: 5px;
background-color: $blue;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
letter-spacing: 0.5px;
transition: transform 0.5s ease, box-shadow 0.3s ease;
&:hover {
background-color: $blue-hover;
transform: scale(1.03);
}
&:not(:last-of-type) {
margin-bottom: 0.7rem;
}
}
.title-container {
width: 100%;
position: absolute;
top: 0;
padding-left: 3rem;
h1 {
font-size: 5rem;
display: inline;
color: $c-green;
-webkit-text-stroke: 1.1px $c-green-dark;
// border: 1px solid $c-green;
// text-shadow: -1px 0 $c-green-dark, 0 1px $c-green-dark, 1px 0 $c-green-dark, 0 -1px $c-green-dark;
}
h2 {
font-size: 2.7rem;
display: inline;
color: $c-green;
-webkit-text-stroke: 1.1px $c-green-dark;
// border: 1px solid $c-green;
// text-shadow: -1px 0 $c-green-dark, 0 1px $c-green-dark, 1px 0 $c-green-dark, 0 -1px $c-green-dark;
}
}
.info-container {
width: calc(100% - 80px);
position: absolute;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
// background-color: red;
margin: 40px;
.button {
@include button;
&--grey {
@include button;
background-color: $grey;
&:hover{
background-color: $grey-hover;
}
span { color: white; }
}
span { color: white; }
}
}
.wrapper {
$width: 88vw;
position: absolute;
left: calc(50% - #{$width} / 2);
display: flex;
flex-direction: column;
width: $width;
height: 82vh;
// -webkit-mask-size: auto 50%;
}
.backdrop {
width: 100%;
height: 100%;
// -webkit-mask-image: linear-gradient(to top, transparent 5%, black);
border-radius: 0 0 8px 8px;
}
.opacity-overlay-bottom {
// background-color: #0c0a09;
position: absolute;
bottom: 0;
height: 55%;
width: 100%;
align-self: flex-end;
-webkit-mask: linear-gradient(to bottom, transparent 0%, black 100%);
mask: linear-gradient(to top, transparent 25%, black 75%);
-webkit-mask: linear-gradient(transparent, black);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-radius: 0 0 8px 8px;
// background: linear-gradient(180deg, rgba(0,0,0,0.29315476190476186) 0%, rgba(0,0,0,1) 100%, rgba(9,9,121,1) 100%, rgba(0,0,0,1) 100%);
}
</style>
<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 LoadingPlaceholder from './ui/LoadingPlaceholder.vue'
import { getMovie, getShow, request, getRequestStatus } from '@/api.js'
export default {
props: ['id', 'type'],
components: { TorrentList, Person, LoadingPlaceholder, SidebarAction },
directives: { img: img }, // TODO decide to remove or use
computed: {
backdropStyle() {
let value = ''
if (this.movie && this.backdrop) {
value = 'url(' + this.ASSET_URL + this.ASSET_SIZES[2] + this.backdrop + ')'
// value = 'url(https://kevinmidboe.com/assets/arrival.jpg)'
console.log('value', value)
}
console.log('bool', this.backdrop)
return { 'background-image': value, 'background-size': 'cover', 'background-repeat': 'no-repeat'}
return { 'background-image': movie && backdrop !== null ? 'url(' + this.ASSET_URL + ASSET_SIZES[1] + backdrop + ')' : '' }
}
},
data(){
return{
ASSET_URL: 'https://image.tmdb.org/t/p/',
ASSET_SIZES: ['w500', 'w780', 'original'],
movie: undefined,
title: undefined,
poster: undefined,
backdrop: undefined,
matched: false,
userLoggedIn: storage.sessionId ? true : false,
requested: false,
admin: localStorage.getItem('admin'),
showTorrents: false
}
},
methods: {
parseResponse(resp) {
const l = {fe: 0}
console.log('resp', 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
},
},
watch: {
id: function(val){
if (this.type === 'movie') {
this.fetchMovie(val);
} else {
this.fetchShow(val)
}
}
},
beforeDestroy() {
document.title = this.prevDocumentTitle
},
created(){
console.log('👋')
this.prevDocumentTitle = document.title
if (this.type === 'movie') {
getMovie(this.id)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: '404' });
})
} else if (this.type === 'show') {
getShow(this.id)
.then(this.parseResponse)
.catch(error => {
this.$router.push({ name: '404' });
})
} else {
console.error('No type found, unable to fetch item')
}
console.log('admin: ', this.admin)
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/loading-placeholder";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.movie {
&__wrap {
display: flex;
&--header {
align-items: center;
height: 100%;
}
&--main {
display: flex;
flex-wrap: wrap;
flex-direction: column;
@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;
}
}
&__img {
display: block;
width: 100%;
opacity: 0;
transform: scale(0.97) translateZ(0);
transition: opacity 0.5s ease, transform 0.5s ease;
&.is-loaded {
opacity: 1;
transform: scale(1);
}
}
&__title {
position: relative;
padding: 20px;
color: $c-green;
text-align: center;
width: 100%;
@include tablet-min {
width: 55%;
text-align: left;
margin-left: 45%;
padding: 30px 30px 30px 40px;
}
h1 {
font-weight: 500;
line-height: 1.4;
font-size: 24px;
@include tablet-min {
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;
}
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;
order: 1;
@include tablet-min {
order: 2;
padding: 40px;
width: 55%;
margin-left: 45%;
}
}
&__actions + &__info {
margin-left: 0;
}
&__description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
}
&__details {
&-block {
float: left;
}
&-block:not(:last-child) {
margin-bottom: 20px;
margin-right: 20px;
@include tablet-min {
margin-bottom: 30px;
margin-right: 30px;
}
}
&-title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
font-size: 14px;
color: $c-green;
@include tablet-min {
font-size: 16px;
}
}
&-text {
font-weight: 300;
font-size: 14px;
margin-top: 5px;
}
}
&__admin {
width: 100%;
padding: 20px;
order: 2;
@include tablet-min {
order: 3;
padding: 40px;
padding-top: 0px;
width: 100%;
}
&-title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
text-align: center;
font-size: 14px;
color: $c-green;
padding-bottom: 20px;
@include tablet-min {
font-size: 16px;
}
}
}
}
</style>

456
src/components/Movie.vue Normal file
View File

@@ -0,0 +1,456 @@
<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>
<div class="movie__title">
<h1>{{ title }}</h1>
</div>
</div>
</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
@click="sendRequest"
:text="'Request to be downloaded?'" :iconRef="'#iconSent'"
: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>
</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>
</div>
<!-- MOVIE INFO -->
<div class="movie__info">
<div class="movie__description" v-if="movie"> {{ movie.overview }}</div>
<!-- Loading placeholder -->
<div v-else class="movie__description">
<loading-placeholder :count="12" />
</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>
<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>
<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>
<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>
</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>
</div>
<!-- TORRENT LIST -->
<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 LoadingPlaceholder from './ui/LoadingPlaceholder.vue'
import { getMovie, getShow, request, getRequestStatus } from '@/api.js'
export default {
props: ['id', 'type'],
components: { TorrentList, Person, LoadingPlaceholder, SidebarAction },
directives: { img: img }, // TODO decide to remove or use
data(){
return{
ASSET_URL: 'https://image.tmdb.org/t/p/',
ASSET_SIZES: ['w500', 'w780', 'original'],
movie: undefined,
title: undefined,
poster: undefined,
backdrop: undefined,
matched: false,
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
},
},
watch: {
id: function(val){
if (this.type === 'movie') {
this.fetchMovie(val);
} else {
this.fetchShow(val)
}
}
},
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' });
})
}
console.log('admin: ', this.admin)
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/loading-placeholder";
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.movie {
&__wrap {
display: flex;
&--header {
align-items: center;
height: 100%;
}
&--main {
display: flex;
flex-wrap: wrap;
flex-direction: column;
@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;
}
}
&__img {
display: block;
width: 100%;
opacity: 0;
transform: scale(0.97) translateZ(0);
transition: opacity 0.5s ease, transform 0.5s ease;
&.is-loaded {
opacity: 1;
transform: scale(1);
}
}
&__title {
position: relative;
padding: 20px;
color: $c-green;
text-align: center;
width: 100%;
@include tablet-min {
width: 55%;
text-align: left;
margin-left: 45%;
padding: 30px 30px 30px 40px;
}
h1 {
font-weight: 500;
line-height: 1.4;
font-size: 24px;
@include tablet-min {
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;
}
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;
order: 1;
@include tablet-min {
order: 2;
padding: 40px;
width: 55%;
margin-left: 45%;
}
}
&__actions + &__info {
margin-left: 0;
}
&__description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
}
&__details {
&-block {
float: left;
}
&-block:not(:last-child) {
margin-bottom: 20px;
margin-right: 20px;
@include tablet-min {
margin-bottom: 30px;
margin-right: 30px;
}
}
&-title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
font-size: 14px;
color: $c-green;
@include tablet-min {
font-size: 16px;
}
}
&-text {
font-weight: 300;
font-size: 14px;
margin-top: 5px;
}
}
&__admin {
width: 100%;
padding: 20px;
order: 2;
@include tablet-min {
order: 3;
padding: 40px;
padding-top: 0px;
width: 100%;
}
&-title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
text-align: center;
font-size: 14px;
color: $c-green;
padding-bottom: 20px;
@include tablet-min {
font-size: 16px;
}
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,78 @@
<template>
<div class="movie-popup" @click="$popup.close()">
<div class="movie-popup__box" @click.stop>
<!-- <movie :id="id" :type="type"></movie> -->
<large-movie :id="id" :type="type"></large-movie>
<button class="movie-popup__close" @click="$popup.close()"></button>
</div>
<i class="loader"></i>
</div>
</template>
<script>
import Movie from './Movie.vue';
import LargeMovie from './LargeMovie.vue';
export default {
props: ['id', 'type'],
components: { Movie, LargeMovie },
created(){
let that = this
window.addEventListener('keyup', function(e){
if (e.keyCode == 27) {
that.$popup.close()
}
})
}
}
</script>
<style lang="scss">
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.movie-popup{
position: fixed;
top: 0;
left: 0;
z-index: 20;
width: 100%;
height: 100vh;
background: rgba($c-dark, 0.93);
-webkit-overflow-scrolling: touch;
overflow: auto;
&__close{
display: block;
position: absolute;
top: 0;
right: 0;
border: 0;
background: transparent;
width: 40px;
height: 40px;
transition: background 0.5s ease;
cursor: pointer;
&:before,
&:after{
content: "";
display: block;
position: absolute;
top: 19px;
left: 10px;
width: 20px;
height: 2px;
background: $c-white;
}
&:before{
transform: rotate(45deg);
}
&:after{
transform: rotate(-45deg);
}
&:hover{
background: $c-green;
}
}
}
</style>

View File

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

@@ -0,0 +1,136 @@
<template>
<li class="movies-item" :class="{'shortList': shortList}">
<a class="movies-item__link" :class="{'no-image': noImage}" @click.prevent="openMoviePopup(movie.id, movie.type)">
<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>
</a>
</li>
</template>
<script>
import img from '../directives/v-image.js'
export default {
props: ['movie', 'shortList'],
directives: {
img: img
},
data(){
return{
noImage: false
}
},
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)
}
}
}
</script>
<style lang="scss">
@import "./src/scss/variables";
@import "./src/scss/media-queries";
.movies-item {
padding: 10px;
width: 50%;
@include tablet-min{
padding: 15px;
}
@include tablet-landscape-min{
padding: 20px;
width: 25%;
}
@include desktop-min{
padding: 30px;
width: 20%;
}
@include desktop-lg-min{
padding: 20px;
width: 16.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
}
}
&__link{
text-decoration: none;
color: rgba($c-dark, 0.5);
font-weight: 300;
}
&__content{
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);
}
}
&__link:not(.no-image):hover &__poster{
transform: scale(1.03);
box-shadow: 0 0 10px rgba($c-dark, 0.1);
}
&__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;
}
}
</style>

View File

@@ -0,0 +1,307 @@
<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>
</template>
<script>
import storage from '@/storage.js'
export default {
data(){
return {
listTypes: storage.homepageLists,
userLoggedIn: localStorage.getItem('token') ? true : false
}
},
methods: {
setUserStatus(){
this.userLoggedIn = localStorage.getItem('token') ? true : false;
},
toggleNav(){
document.querySelector('.nav__hamburger').classList.toggle('nav__hamburger--active');
document.querySelector('.nav__list').classList.toggle('nav__list--active');
}
},
created(){
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;
}
}
.nav {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 50px;
background: $c-white;
z-index: 10;
display: block;
@include tablet-min{
width: 95px;
height: 100vh;
}
&__logo{
width: 55px;
height: $header-size-mobile;
display: flex;
align-items: center;
justify-content: center;
background: $c-dark;
@include tablet-min{
width: 95px;
height: $header-size;
}
&-image{
width: 35px;
height: 31px;
fill: $c-green;
transition: transform 0.5s ease;
@include tablet-min{
width: 45px;
height: 40px;
}
}
&:hover &-image{
transform: scale(1.04);
}
}
&__hamburger{
display: block;
position: fixed;
width: 55px;
height: 50px;
top: 0;
right: 0;
cursor: pointer;
background: $c-white;
z-index: 10;
border-left: 1px solid $c-light;
@include tablet-min{
display: none;
}
.bar{
position: absolute;
width: 23px;
height: 1px;
background: rgba($c-dark, 0.5);
transition: all 300ms ease;
&:nth-child(1){
left: 16px;
top: 17px;
}
&:nth-child(2){
left: 16px;
top: 25px;
&:after {
content: "";
position: absolute;
left: 0px;
top: 0px;
width: 23px;
height: 1px;
background: transparent;
transition: all 300ms ease;
}
}
&:nth-child(3){
right: 15px;
top: 33px;
}
}
&--active{
.bar{
&:nth-child(1),
&:nth-child(3){
width: 0;
}
&:nth-child(2) {
transform: rotate(-45deg);
}
&:nth-child(2):after {
transform: rotate(-90deg);
background: rgba($c-dark, 0.5);
}
}
}
}
&__list{
list-style: none;
padding: 0;
margin: 0;
text-align: center;
width: 100%;
position: fixed;
left: 0;
top: 50px;
background: rgba($c-white, 0.98);
border-top: 1px solid $c-light;
@include mobile-only{
font-size: 0;
opacity: 0;
visibility: hidden;
height: calc(100vh - 50px);
transition: all 0.5s ease;
text-align: left;
&--active{
opacity: 1;
visibility: visible;
}
}
@include tablet-min{
display: flex;
background: transparent;
position: relative;
display: block;
width: 100%;
border-top: 0;
top: 0;
}
}
&__item{
@include mobile-only{
display: inline-block;
text-align: center;
width: 50%;
border-bottom: 1px solid $c-light;
&:nth-child(odd){
border-right: 1px solid $c-light;
}
}
@include tablet-min{
width: 100%;
border-bottom: 1px solid $c-light;
&--profile{
position: fixed;
right: 0;
top: 0;
width: $header-size;
height: $header-size;
border-bottom: 0;
border-left: 1px solid $c-light;
}
}
}
&__link{
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
font-size: 7px;
font-weight: 300;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba($c-dark, 0.7);
transition: color 0.5s ease, background 0.5s ease;
position: relative;
cursor: pointer;
&-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
@include mobile-only{
font-size: 10px;
padding: 20px 0;
}
@include tablet-min{
width: 95px;
height: 95px;
font-size: 9px;
&--profile{
width: 75px;
height: 75px;
background: $c-white;
}
}
&-icon{
width: 20px;
height: 20px;
fill: rgba($c-dark, 0.7);
transition: fill 0.5s ease;
@include tablet-min{
width: 20px;
height: 20px;
margin-bottom: 5px;
}
}
&-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;
}
}
}
</style>

View File

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

63
src/components/Person.vue Normal file
View File

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

View File

@@ -1,237 +0,0 @@
<template>
<div
v-if="isOpen"
ref="popupContainer"
class="movie-popup"
role="dialog"
aria-modal="true"
tabindex="-1"
@click="close"
@keydown.enter="close"
@keydown="handleKeydown"
>
<div class="movie-popup__box" @click.stop>
<person v-if="type === 'person'" :id="id" type="person" />
<movie v-else :id="id" :type="type"></movie>
<button class="movie-popup__close" @click="close" tabindex="0"></button>
</div>
<i class="loader"></i>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
import { useStore } from "vuex";
import Movie from "@/components/popup/Movie.vue";
import Person from "@/components/popup/Person.vue";
import type { Ref } from "vue";
import { MediaTypes } from "../interfaces/IList";
interface URLQueryParameters {
id: number;
type: MediaTypes;
}
const store = useStore();
const isOpen: Ref<boolean> = ref();
const id: Ref<string> = ref();
const type: Ref<MediaTypes> = ref();
const popupContainer = ref<HTMLElement | null>(null);
let previouslyFocusedElement: HTMLElement | null = null;
const unsubscribe = store.subscribe((mutation, state) => {
if (!mutation.type.includes("popup")) return;
isOpen.value = state.popup.open;
id.value = state.popup.id;
type.value = state.popup.type;
if (isOpen.value) {
document.getElementsByTagName("body")[0].classList.add("no-scroll");
} else {
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
}
});
function getFromURLQuery(): URLQueryParameters {
let _id: number;
let _type: MediaTypes;
const params = new URLSearchParams(window.location.search);
params.forEach((_, key) => {
if (
key !== MediaTypes.Movie &&
key !== MediaTypes.Show &&
key !== MediaTypes.Person
) {
return;
}
_id = Number(params.get(key));
_type = key;
});
return { id: _id, type: _type };
}
function open(_id: number, _type: string) {
if (!_id || !_type) return;
store.dispatch("popup/open", { id: _id, type: _type });
}
function close() {
store.dispatch("popup/close");
}
function checkEventForEscapeKey(event: KeyboardEvent) {
if (event.keyCode !== 27) return;
close();
}
function getFocusableElements(): HTMLElement[] {
if (!popupContainer.value) return [];
const focusableSelectors = [
"button:not([disabled])",
"a[href]",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])'
].join(", ");
return Array.from(
popupContainer.value.querySelectorAll(focusableSelectors)
) as HTMLElement[];
}
function trapFocus(event: KeyboardEvent) {
if (event.key !== "Tab") return;
const focusableElements = getFocusableElements();
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
function handleKeydown(event: KeyboardEvent) {
trapFocus(event);
}
function setInitialFocus() {
nextTick(() => {
// Focus the popup container itself instead of a specific element
// This allows tab to start fresh without any element being focused
if (popupContainer.value) {
popupContainer.value.focus();
}
});
}
watch(isOpen, newValue => {
if (newValue) {
// Store the previously focused element
previouslyFocusedElement = document.activeElement as HTMLElement;
// Set focus to popup
setInitialFocus();
} else {
// Restore focus to previously focused element
if (previouslyFocusedElement) {
previouslyFocusedElement.focus();
}
}
});
window.addEventListener("keyup", checkEventForEscapeKey);
onMounted(() => {
const query = getFromURLQuery();
open(query?.id, query?.type);
});
onBeforeUnmount(() => {
unsubscribe();
window.removeEventListener("keyup", checkEventForEscapeKey);
});
</script>
<style lang="scss">
@import "scss/variables";
@import "scss/media-queries";
.movie-popup {
position: fixed;
top: 0;
left: 0;
z-index: 20;
width: 100%;
height: 100%;
background: rgba($dark, 0.93);
-webkit-overflow-scrolling: touch;
overflow: auto;
&:focus {
outline: none;
}
&__box {
max-width: 768px;
position: relative;
z-index: 5;
margin: 8vh auto;
@include mobile {
margin: 0 0 50px 0;
}
}
&__close {
display: block;
position: absolute;
top: 0;
right: 0;
border: 0;
background: transparent;
width: 40px;
height: 40px;
transition: background 0.5s ease;
cursor: pointer;
z-index: 5;
&:before,
&:after {
content: "";
display: block;
position: absolute;
top: 19px;
left: 10px;
width: 20px;
height: 2px;
background-color: white;
}
&:before {
transform: rotate(45deg);
}
&:after {
transform: rotate(-45deg);
}
&:hover {
background-color: var(--highlight-color);
}
}
}
</style>

126
src/components/Profile.vue Normal file
View File

@@ -0,0 +1,126 @@
<template>
<section class="profile">
<div class="profile__content" v-if="userLoggedIn">
<header class="profile__header">
<h2 class="profile__title">{{ emoji }} Welcome {{ userName }}</h2>
<div class="button--group">
<seasoned-button @click="showSettings = !showSettings">{{ 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>
</div>
<section class="not-found" v-if="!userLoggedIn">
<div class="not-found__content">
<h2 class="not-found__title">Authentication Request Failed</h2>
<router-link :to="{name: 'signin'}" exact title="Sign in here">
<button class="not-found__button button">Sign In</button>
</router-link>
</div>
</section>
</section>
</template>
<script>
import storage from '@/storage.js'
import MoviesList from '@/components/MoviesList.vue'
import Settings from '@/components/Settings.vue'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
import { getEmoji } from '@/api.js'
// import CreatedLists from './CreatedLists.vue'
export default {
components: { MoviesList, Settings, SeasonedButton },
data(){
return{
userLoggedIn: '',
userName: '',
emoji: '',
showSettings: false,
user_requestsList: storage.user_requestsList
}
},
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;
},
logOut(){
localStorage.clear();
eventHub.$emit('setUserStatus');
this.$router.push({ name: 'home' });
}
},
created(){
document.title = 'Profile' + storage.pageTitlePostfix;
storage.backTitle = document.title;
if(!localStorage.getItem('token')){
this.userLoggedIn = false;
} else {
this.userLoggedIn = true;
this.getUserInfo();
getEmoji()
.then(resp => this.emoji = resp.data.emoji )
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
// DUPLICATE CODE
.profile{
&__header{
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid rgba($c-dark, 0.05);
@include tablet-min{
padding: 29px 30px;
}
@include tablet-landscape-min{
padding: 29px 50px;
}
@include desktop-min{
padding: 29px 60px;
}
}
&__title{
margin: 0;
font-size: 16px;
line-height: 16px;
color: $c-dark;
font-weight: 300;
@include tablet-min{
font-size: 18px;
line-height: 18px;
}
}
}
</style>

202
src/components/Register.vue Normal file
View File

@@ -0,0 +1,202 @@
<template>
<section class="profile">
<div class="profile__content">
<h2 class='settings__header'>Register new user</h2>
<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>
<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>
<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>
</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>
</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'
export default {
components: { SeasonedButton, SeasonedInput },
data(){
return{
userLoggedIn: '',
username: undefined,
password: undefined,
passwordRepeat: undefined,
showMessage: false,
messageClass: 'message-success',
messageText: 'hello world'
}
},
methods: {
requestNewUser(){
let username = this.username
let password = this.password
let password_re = this.passwordRepeat
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 (data.success){
this.msg(data.message, 'success');
localStorage.setItem('token', data.token);
localStorage.setItem('username', username);
localStorage.setItem('admin', data.admin)
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
},
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;
&__header {
margin: 0;
line-height: 16px;
color: $c-dark;
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 {
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,76 +0,0 @@
<template>
<div>
<ul
v-if="results && results.length"
class="results"
:class="{ shortList: shortList }"
>
<results-list-item
v-for="(result, index) in results"
:key="generateResultKey(index, `${result.type}-${result.id}`)"
:list-item="result"
/>
</ul>
<span v-else-if="!loading" class="no-results">No results found</span>
</div>
</template>
<script setup lang="ts">
import ResultsListItem from "@/components/ResultsListItem.vue";
import type { ListResults } from "../interfaces/IList";
interface Props {
results: Array<ListResults>;
shortList?: boolean;
loading?: boolean;
}
defineProps<Props>();
function generateResultKey(index: string | number | symbol, value: string) {
return `${String(index)}-${value}`;
}
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
@import "scss/main";
.no-results {
width: 100%;
display: block;
text-align: center;
margin: 1.5rem;
font-size: 1.2rem;
}
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
grid-auto-rows: auto;
margin: 0;
padding: 0;
list-style: none;
@include mobile {
grid-template-columns: repeat(2, 1fr);
}
&.shortList {
overflow: auto;
grid-auto-flow: column;
max-width: 100vw;
@include noscrollbar;
> li {
min-width: 225px;
}
@include tablet-min {
max-width: calc(100vw - var(--header-size));
}
}
}
</style>

View File

@@ -1,187 +0,0 @@
<template>
<li ref="list-item" class="movie-item">
<figure
ref="posterElement"
class="movie-item__poster"
@click="openMoviePopup"
@keydown.enter="openMoviePopup"
>
<img
class="movie-item__img"
:alt="posterAltText"
:data-src="poster"
src="/assets/placeholder.png"
/>
<div v-if="listItem.download" class="progress">
<progress :value="listItem.download.progress" max="100"></progress>
<span
>{{ listItem.download.state }}:
{{ listItem.download.progress }}%</span
>
</div>
</figure>
<div
class="movie-item__info"
@click="openMoviePopup"
@keydown.enter="openMoviePopup"
>
<p v-if="listItem.title || listItem.name" class="movie-item__title">
{{ listItem.title || listItem.name }}
</p>
<p v-if="listItem.year">{{ listItem.year }}</p>
<p v-if="listItem.type == 'person'">
Known for: {{ listItem.known_for_department }}
</p>
</div>
</li>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import type { Ref } from "vue";
import type { IMovie, IShow, IPerson } from "../interfaces/IList";
interface Props {
listItem: IMovie | IShow | IPerson;
}
const props = defineProps<Props>();
const store = useStore();
const IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w500";
const IMAGE_FALLBACK = "/assets/no-image.svg";
const poster: Ref<string> = ref();
const posterElement: Ref<HTMLElement> = ref(null);
const observed: Ref<boolean> = ref(false);
if (props.listItem?.poster) {
poster.value = IMAGE_BASE_URL + props.listItem.poster;
} else {
poster.value = IMAGE_FALLBACK;
}
const posterAltText = computed(() => {
const type = props.listItem.type || "";
let title = "";
if ("name" in props.listItem) title = props.listItem.name;
else if ("title" in props.listItem) title = props.listItem.title;
return props.listItem.poster
? `Poster for ${type} ${title}`
: `Missing image for ${type} ${title}`;
});
function observePosterAndSetImageSource() {
const imageElement = posterElement.value.getElementsByTagName("img")[0];
if (imageElement == null) return;
const imageObserver = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting && observed.value === false) {
const lazyImage = entry.target as HTMLImageElement;
lazyImage.src = lazyImage.dataset.src;
posterElement.value.classList.add("is-loaded");
observed.value = true;
}
});
});
imageObserver.observe(imageElement);
}
onMounted(observePosterAndSetImageSource);
function openMoviePopup() {
store.dispatch("popup/open", { ...props.listItem });
}
// const imageSize = computed(() => {
// if (!posterElement.value) return;
// const { height, width } = posterElement.value.getBoundingClientRect();
// return {
// height: Math.ceil(height),
// width: Math.ceil(width)
// };
// });
// import img from "../directives/v-image";
// directives: {
// img: img
// },
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
.movie-item {
padding: 15px;
width: 100%;
background-color: var(--background-color);
&:hover &__info > p {
color: $text-color;
}
&__poster {
text-decoration: none;
color: $text-color-70;
font-weight: 300;
position: relative;
transform: scale(0.97) translateZ(0);
&::before {
content: "";
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
background-color: var(--background-color);
transition: 1s background-color ease;
}
&:hover {
transform: scale(1.03);
box-shadow: 0 0 10px rgba($dark, 0.1);
}
&.is-loaded::before {
background-color: transparent;
}
img {
width: 100%;
border-radius: 10px;
}
}
&__info {
padding-top: 10px;
font-weight: 300;
> p {
color: $text-color-70;
margin: 0;
font-size: 14px;
letter-spacing: 0.5px;
transition: color 0.5s ease;
cursor: pointer;
@include mobile-ls-min {
font-size: 12px;
}
@include tablet-min {
font-size: 14px;
}
}
}
&__title {
font-weight: 400;
}
}
</style>

View File

@@ -1,203 +0,0 @@
<template>
<div ref="resultSection" class="resultSection">
<page-header v-bind="{ title, info, shortList }" />
<div
v-if="!loadedPages.includes(1) && loading == false"
class="button-container"
>
<seasoned-button class="load-button" :full-width="true" @click="loadLess"
>load previous</seasoned-button
>
</div>
<results-list v-bind="{ results, shortList, loading }" />
<loader v-if="loading" />
<div ref="loadMoreButton" class="button-container">
<seasoned-button
v-if="!loading && !shortList && page != totalPages && results.length"
class="load-button"
:full-width="true"
@click="loadMore"
>load more</seasoned-button
>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import PageHeader from "@/components/PageHeader.vue";
import ResultsList from "@/components/ResultsList.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import Loader from "@/components/ui/Loader.vue";
import type { Ref } from "vue";
import type { IList, ListResults } from "../interfaces/IList";
import type ISection from "../interfaces/ISection";
interface Props extends ISection {
title: string;
apiFunction: (page: number) => Promise<IList>;
shortList?: boolean;
}
const props = defineProps<Props>();
const results: Ref<ListResults> = ref([]);
const page: Ref<number> = ref(1);
const loadedPages: Ref<number[]> = ref([]);
const totalResults: Ref<number> = ref(0);
const totalPages: Ref<number> = ref(0);
const loading: Ref<boolean> = ref(true);
const autoLoad: Ref<boolean> = ref(false);
const observer: Ref<IntersectionObserver> = ref(null);
const resultSection = ref(null);
const loadMoreButton = ref(null);
function pageCountString(_page: number, _totalPages: number) {
return `Page ${_page} of ${_totalPages}`;
}
function resultCountString(_results: ListResults, _totalResults: number) {
const loadedResults = _results.length;
const __totalResults = _totalResults < 10000 ? _totalResults : "∞";
return `${loadedResults} of ${__totalResults} results`;
}
function setLoading(state: boolean) {
loading.value = state;
}
const info = computed(() => {
if (results.value.length === 0) return [null, null];
const pageCount = pageCountString(page.value, totalPages.value);
const resultCount = resultCountString(results.value, totalResults.value);
return [pageCount, resultCount];
});
function getPageFromUrl() {
const _page = new URLSearchParams(window.location.search).get("page");
if (!_page) return null;
return Number(_page);
}
function updateQueryParams() {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) {
params.set("page", page.value?.toString());
} else if (page.value > 1) {
params.append("page", page.value?.toString());
}
window.history.replaceState(
{},
"search",
`${window.location.protocol}//${window.location.hostname}${
window.location.port ? `:${window.location.port}` : ""
}${window.location.pathname}${
params.toString().length ? `?${params}` : ""
}`
);
}
function getListResults(front = false) {
props
.apiFunction(page.value)
.then(listResponse => {
if (!front)
results.value = results.value.concat(...listResponse.results);
else results.value = listResponse.results.concat(...results.value);
page.value = listResponse.page;
loadedPages.value.push(page.value);
loadedPages.value = loadedPages.value.sort((a, b) => a - b);
totalPages.value = listResponse.total_pages;
totalResults.value = listResponse.total_results;
})
.then(updateQueryParams)
.finally(() => setLoading(false));
}
function loadMore() {
if (!autoLoad.value) {
autoLoad.value = true;
}
loading.value = true;
const maxPage = [...loadedPages.value].slice(-1)[0];
if (Number.isNaN(maxPage)) return;
page.value = maxPage + 1;
getListResults();
}
function loadLess() {
loading.value = true;
const minPage = loadedPages.value[0];
if (minPage === 1) return;
page.value = minPage - 1;
getListResults(true);
}
function handleButtonIntersection(entries) {
entries.map(entry =>
entry.isIntersecting && autoLoad.value ? loadMore() : null
);
}
function setupAutoloadObserver() {
observer.value = new IntersectionObserver(handleButtonIntersection, {
root: resultSection.value.$el,
rootMargin: "0px",
threshold: 0
});
observer.value.observe(loadMoreButton.value);
}
page.value = getPageFromUrl() || page.value;
if (results.value?.length === 0) getListResults();
onMounted(() => {
if (!props?.shortList) setupAutoloadObserver();
});
// beforeDestroy() {
// this.observer = undefined;
// }
// };
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.resultSection {
background-color: var(--background-color);
}
.button-container {
display: flex;
justify-content: center;
display: flex;
width: 100%;
}
.load-button {
margin: 2rem 0;
@include mobile {
margin: 1rem 0;
}
&:last-of-type {
margin-bottom: 4rem;
@include mobile {
margin-bottom: 2rem;
}
}
}
</style>

View File

@@ -0,0 +1,277 @@
<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>
<transition name="fade">
<div class="dropdown" v-if="!disabled && focus && query.length > 0">
<div class="dropdown--results">
<ul v-for="(item, index) in elasticSearchResults"
@click="$popup.open(item.id, item.type)"
:class="{ active: index + 1 === selectedResult}">
{{ item.name }}
</ul>
</div>
<seasoned-button class="end-section" fullWidth="true"
@click="focus = false" :active="elasticSearchResults.length + 1 === selectedResult">
close
</seasoned-button>
</div>
</transition>
</div>
</template>
<script>
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
import { elasticSearchMoviesAndShows } from '@/api.js'
import config from '@/config.json'
export default {
name: 'SearchInput',
components: {
SeasonedButton
},
props: ['value'],
data() {
return {
query: this.value,
focus: false,
disabled: false,
scrollListener: undefined,
scrollDistance: 0,
elasticSearchResults: '',
selectedResult: 0
}
},
watch: {
focus: function(val) {
if (val === true) {
window.addEventListener('scroll', this.disableFocus)
} else {
window.removeEventListener('scroll', this.disableFocus)
this.scrollDistance = 0
}
}
},
beforeMount() {
const elasticUrl = config.ELASTIC_URL
if (elasticUrl === undefined || elasticUrl === false || elasticUrl === '') {
this.disabled = true
}
},
beforeDestroy() {
console.log('scroll eventlistener not removed, destroying!')
window.removeEventListener('scroll', this.disableFocus)
},
methods: {
navigateDown() {
this.focus = true
this.selectedResult++
},
navigateUp() {
this.focus = true
this.selectedResult--
},
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
this.elasticSearchResults = data.map(item => {
const index = item._index.slice(0, -1)
if (index === 'movie') {
return {
name: item._source.original_title,
id: item._source.id,
type: index
}
} else if (index === 'show') {
return {
name: item._source.original_name,
id: item._source.id,
type: index
}
}
})
console.log(this.elasticSearchResults)
})
},
handleSubmit() {
let searchResults = this.elasticSearchResults
if (this.selectedResult > searchResults.length) {
this.focus = false
this.selectedResult = 0
} else if (this.selectedResult > 0) {
const resultItem = searchResults[this.selectedResult - 1]
this.$popup.open(resultItem.id, resultItem.type)
} else {
const encodedQuery = encodeURI(this.query.replace('/ /g, "+"'))
this.$router.push({ name: 'search', query: { query: encodedQuery }});
this.focus = false
this.selectedResult = 0
}
},
handleEscape() {
if (this.$popup.isOpen) {
console.log('THIS WAS FUCKOING OPEN!')
} else {
this.focus = false
}
},
disableFocus(_) {
this.focus = false
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
@import './src/scss/main';
.fade-enter-active {
transition: opacity .2s;
}
.fade-leave-active {
transition: opacity .2s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.dropdown {
width: 100%;
position: relative;
display: flex;
flex-wrap: wrap;
z-index: 5;
min-height: $header-size;
right: 0px;
background-color: white;
@include mobile-only {
position: fixed;
top: 50px;
padding-top: 20px;
width: calc(100%);
}
&--results {
padding-left: 60px;
width: 100%;
@include mobile-only {
padding-left: 45px;
}
> ul {
font-size: 1.3rem;
padding: 0;
margin: 0.2rem 0;
width: calc(100% - 25px);
max-width: fit-content;
list-style: none;
color: rgba(0, 0, 0, 0.5);
text-transform: capitalize;
cursor: pointer;
border-bottom: 2px solid transparent;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&.active, &:hover, &:active {
color: $c-dark;
border-bottom: 2px solid black;
}
}
}
}
.search {
height: $header-size-mobile;
display: flex;
position: fixed;
flex-wrap: wrap;
z-index: 5;
// TODO check if this is for mobile
width: calc(100% - 110px);
// width: 100%;
top: 0;
right: 55px;
@include tablet-min{
position: relative;
height: $header-size;
width: 100%;
right: 0px;
}
input {
// height: 75px;
display: block;
width: 100%;
padding: 13px 20px 13px 45px;
outline: none;
border: 0;
background-color: transparent;
color: $c-dark;
font-weight: 300;
font-size: 19px;
@include tablet-min {
padding: 13px 30px 13px 60px;
}
}
&--icon{
width: 20px;
height: 20px;
fill: rgba($c-dark, 0.5);
transition: fill 0.5s ease;
pointer-events: none;
position: absolute;
left: 15px;
top: 15px;
@include tablet-min{
top: 27px;
left: 25px;
}
}
}
</style>

156
src/components/Settings.vue Normal file
View File

@@ -0,0 +1,156 @@
<template>
<section class="profile">
<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)"/>
<seasoned-button @click="authenticatePlex">link plex account</seasoned-button>
</form>
<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-button @click="changePassword">change password</seasoned-button>
</form>
<hr class='setting__divider'>
</section>
</div>
<section class="not-found" v-else>
<div class="not-found__content">
<h2 class="not-found__title">Authentication Request Failed</h2>
<router-link :to="{name: 'signin'}" exact title="Sign in here">
<button class="not-found__button button">Sign In</button>
</router-link>
</div>
</section>
</section>
</template>
<script>
import storage from '@/storage.js'
import SeasonedInput from '@/components/ui/SeasonedInput.vue'
import SeasonedButton from '@/components/ui/SeasonedButton.vue'
import { plexAuthenticate } from '@/api.js'
export default {
components: { SeasonedInput, SeasonedButton },
data(){
return{
userLoggedIn: '',
plexUsername: undefined,
plexPassword: undefined,
newPass: undefined,
newPassConfirm: undefined
}
},
methods: {
setValue(l, t) {
console.log('l, t', l, t)
this[l] = t
},
changePassword() {
return
},
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)
})
.catch((error) => {
console.log('error: ', error)
})
}
},
created(){
document.title = 'Settings' + storage.pageTitlePostfix;
storage.backTitle = document.title;
if (localStorage.getItem('token')){
this.userLoggedIn = true
}
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
@import "./src/scss/media-queries";
a {
text-decoration: none;
}
// DUPLICATE CODE
.form {
> div:last-child {
margin-top: 1rem;
}
&__group{
justify-content: unset;
&__input-icon {
margin-top: 8px;
height: 22px;
width: 22px;
}
&-input {
padding: 10px 5px 10px 45px;
height: 40px;
font-size: 17px;
width: 75%;
@include desktop-min {
width: 400px;
}
}
}
}
.settings {
padding: 35px;
&__header {
margin: 0;
line-height: 16px;
color: $c-dark;
font-weight: 300;
margin-bottom: 20px;
text-transform: uppercase;
}
&__info {
display: block;
margin-bottom: 25px;
}
hr {
display: block;
height: 1px;
border: 0;
border-bottom: 1px solid rgba(8, 28, 36, 0.05);
margin-top: 30px;
margin-bottom: 70px;
margin-left: 20px;
width: 96%;
text-align: left;
}
span {
font-weight: 200;
size: 16px;
}
}
</style>

158
src/components/Signin.vue Normal file
View File

@@ -0,0 +1,158 @@
<template>
<section class="profile">
<div class="profile__content">
<h2 class='settings__header'>Sign in</h2>
<form class="form">
<div class="form__buffer"></div>
<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="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>
</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'
export default {
components: { SeasonedInput, SeasonedButton },
data(){
return{
userLoggedIn: '',
showMessage: false,
messageClass: 'message-success',
messageText: 'hello world',
username: undefined,
password: undefined
}
},
methods: {
setValue(l, t) {
this[l] = t
},
signin(){
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;
},
},
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;
&__header {
margin: 0;
line-height: 16px;
color: $c-dark;
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 {
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,386 @@
<template>
<div v-if="show">
<h2 class="title">torrents: {{ query }}</h2>
<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>
<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>
</div>
<i v-else class="torrentloader"></i>
</div>
</template>
<script>
import storage from '@/storage.js'
import { sortableSize } from '@/utils.js'
import { searchTorrents, addMagnet } from '@/api.js'
export default {
props: {
query: {
type: String,
require: true
},
tmdb_id: {
type: Number,
require: true
},
tmdb_type: String,
admin: String,
show: Boolean
},
data() {
return {
listLoaded: false,
torrents: undefined,
torrentResponse: undefined,
currentPage: 0,
prevCol: '',
direction: false,
release_types: ['all'],
selectedRelaseType: 'all'
}
},
beforeMount() {
if (localStorage.getItem('admin')) {
this.fetchTorrents()
}
},
methods: {
expand(event, name) {
const existingExpandedElement = document.getElementsByClassName('expanded')[0]
if (existingExpandedElement) {
console.log('exists')
const expandedSibling = event.target.parentNode.nextSibling.className === 'expanded'
existingExpandedElement.remove()
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'
nameCol.innerText = name
nameRow.appendChild(nameCol)
event.target.parentNode.insertAdjacentElement('afterend', nameRow)
},
sendTorrent(magnet, name, event){
this.$notifications.info({
title: 'Adding torrent 🦜',
description: this.query,
timeout: 3000
})
event.target.parentNode.classList.add('active')
addMagnet(magnet, name, this.tmdb_id)
.catch((resp) => { console.log('error:', resp.data) })
.then((resp) => {
console.log('addTorrent resp: ', resp)
this.$notifications.success({
title: 'Torrent added 🎉',
description: this.query,
timeout: 3000
})
})
},
sortTable(col, sameDirection=false) {
if (this.prevCol === col && sameDirection === false) {
this.direction = !this.direction
}
console.log('col and more', col, sameDirection)
switch (col) {
case 'name':
this.sortName()
break
case 'seed':
this.sortSeed()
break
case 'size':
this.sortSize()
break
}
this.prevCol = col
},
sortName() {
const torrentsCopy = [...this.torrents]
if (this.direction) {
this.torrents = torrentsCopy.sort((a, b) => (a.name < b.name) ? 1 : -1)
} else {
this.torrents = torrentsCopy.sort((a, b) => (a.name > b.name) ? 1 : -1)
}
},
sortSeed() {
const torrentsCopy = [...this.torrents]
if (this.direction) {
this.torrents = torrentsCopy.sort((a, b) => parseInt(a.seed) - parseInt(b.seed));
} else {
this.torrents = torrentsCopy.sort((a, b) => parseInt(b.seed) - parseInt(a.seed));
}
},
sortSize() {
const torrentsCopy = [...this.torrents]
if (this.direction) {
this.torrents = torrentsCopy.sort((a, b) => parseInt(sortableSize(a.size)) - parseInt(sortableSize(b.size)));
} else {
this.torrents = torrentsCopy.sort((a, b) => parseInt(sortableSize(b.size)) - parseInt(sortableSize(a.size)));
}
},
findRelaseTypes() {
this.torrents.forEach(item => this.release_types.push(...item.release_type))
this.release_types = [...new Set(this.release_types)]
},
applyFilter(item, index) {
this.selectedRelaseType = item;
const torrents = [...this.torrentResponse]
if (item === 'all') {
this.torrents = torrents
this.sortTable(this.prevCol, true)
return
}
this.torrents = torrents.filter(torrent => torrent.release_type.includes(item))
this.sortTable(this.prevCol, true)
},
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;
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">
@import "./src/scss/variables";
.expanded {
display: flex;
margin: 0 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);
td {
// border-left: 1px solid $c-dark;
word-break: break-all;
padding: 0.5rem 0.15rem;
width: 100%;
}
}
</style>
<style lang="scss" scoped>
@import "./src/scss/variables";
@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;
padding-bottom: 20px;
@include tablet-min{
font-size: 16px;
}
}
table {
border-collapse: collapse;
width: 100%;
table-layout: fixed;
}
.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);
th, td {
display: flex;
flex-direction: column;
flex-basis: 100%;
padding: 0.4rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
th:first-child, td:first-child {
flex: 1;
}
th:not(:first-child), td:not(:first-child) {
flex: 0.2;
}
th:nth-child(2), td:nth-child(2) {
flex: 0.1;
}
@include mobile-only {
th:first-child, td:first-child {
display: none;
&.show {
display: block;
align: flex-end;
}
}
th:not(:first-child), td:not(:first-child) {
flex: 1;
}
}
}
.table__content {
td:not(:last-child) {
border-right: 1px solid rgba($c-dark, 0.8);
}
}
.table__content:last-child {
margin-bottom: 1rem;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
.table__header {
background-color: white;
color: $c-dark;
text-transform: uppercase;
cursor: pointer;
border-top: 1px solid rgba($c-dark, 0.8);
border-top-left-radius: 3px;
border-top-right-radius: 3px;
th {
display: flex;
flex-direction: row;
font-weight: 400;
letter-spacing: 0.7px;
// font-size: 1.08rem;
font-size: 15px;
&::before {
content: '';
min-width: 0.2rem;
}
span:first-child {
margin-right: 0.6rem;
}
span:nth-child(2) {
margin-right: 0.1rem;
}
}
th:not(:last-child) {
border-right: 1px solid rgba($c-dark, 0.8);
}
}
.download {
&__icon {
fill: rgba($c-dark, 0.6);
height: 1.2rem;
&:hover {
fill: $c-dark;
cursor: pointer;
}
}
&.active &__icon {
fill: $c-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;
border-radius: 50%;
content: '';
left: 10px;
position: absolute;
top: 16px;
}
}
@keyframes load {
100% { transform: rotate(360deg); }
}
</style>

View File

@@ -1,86 +0,0 @@
<template>
<div v-if="watchStats" class="stats-overview">
<div class="stat-card">
<div class="stat-value">{{ watchStats.totalPlays }}</div>
<div class="stat-label">Total Plays</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.totalHours }}h</div>
<div class="stat-label">Watch Time</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.moviePlays }}</div>
<div class="stat-label">Movies watched</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ watchStats.episodePlays }}</div>
<div class="stat-label">Episodes watched</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { WatchStats } from "../../composables/useTautulliStats";
interface Props {
watchStats: WatchStats | null;
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
}
.stat-card {
background: var(--background-ui);
padding: 1.5rem;
border-radius: 12px;
text-align: center;
transition: transform 0.2s;
&:hover {
transform: translateY(-4px);
}
@include mobile-only {
padding: 1rem;
}
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
color: var(--highlight-color);
margin-bottom: 0.5rem;
@include mobile-only {
font-size: 2rem;
}
}
.stat-label {
font-size: 0.9rem;
color: var(--text-color-60);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 300;
@include mobile-only {
font-size: 0.8rem;
}
}
</style>

View File

@@ -1,101 +0,0 @@
<template>
<div v-if="topContent.length > 0" class="watch-history">
<h3 class="section-title">Last Watched</h3>
<div class="top-content-list">
<div
v-for="(item, index) in topContent"
:key="index"
class="top-content-item"
>
<div class="content-rank">{{ index + 1 }}</div>
<div class="content-details">
<div class="content-title">{{ item.title }}</div>
<div class="content-meta">
{{ item.type }} {{ item.plays }} plays {{ item.duration }}min
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface TopContentItem {
title: string;
type: string;
plays: number;
duration: number;
}
interface Props {
topContent: TopContentItem[];
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.watch-history {
margin-top: 2rem;
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 500;
color: $text-color;
}
.top-content-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
@include mobile-only {
grid-template-columns: 1fr;
}
}
.top-content-item {
display: flex;
align-items: center;
gap: 1rem;
background: var(--background-ui);
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--text-color-50);
transition: all 0.2s;
&:hover {
border-color: var(--text-color);
transform: translateY(-2px);
}
}
.content-rank {
font-size: 1.5rem;
font-weight: 700;
color: var(--highlight-color);
min-width: 2.5rem;
text-align: center;
}
.content-details {
flex: 1;
}
.content-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin-bottom: 0.25rem;
}
.content-meta {
font-size: 0.85rem;
color: var(--text-color-60);
}
</style>

View File

@@ -1,503 +0,0 @@
<template>
<div class="admin-stats">
<div class="admin-stats__header">
<h2 class="admin-stats__title">Statistics</h2>
<div class="admin-stats__controls">
<select
v-model="timeRange"
class="time-range-select"
@change="fetchStats"
>
<option value="today">Today</option>
<option value="week">This Week</option>
<option value="month">This Month</option>
<option value="all">All Time</option>
</select>
<button class="refresh-btn" @click="fetchStats" :disabled="loading">
<IconActivity :class="{ spin: loading }" />
</button>
</div>
</div>
<div v-if="loading" class="admin-stats__loading">Loading statistics...</div>
<div v-else class="admin-stats__grid">
<div
class="stat-card"
v-for="stat in statCards"
:key="stat.key"
@click="handleCardClick(stat.key)"
:class="{ 'stat-card--clickable': stat.clickable }"
>
<div class="stat-card__header">
<component :is="stat.icon" class="stat-card__icon" />
<span
v-if="stat.trend !== 0"
:class="[
'stat-card__trend',
stat.trend > 0 ? 'stat-card__trend--up' : 'stat-card__trend--down'
]"
>
{{ stat.trend > 0 ? "↑" : "↓" }} {{ Math.abs(stat.trend) }}%
</span>
</div>
<span class="stat-card__value">{{ stat.value }}</span>
<span class="stat-card__label">{{ stat.label }}</span>
<div v-if="stat.sparkline" class="stat-card__sparkline">
<div
v-for="(point, index) in stat.sparkline"
:key="index"
class="sparkline-bar"
:style="{
height: `${(point / Math.max(...stat.sparkline)) * 100}%`
}"
></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconPlay from "@/icons/IconPlay.vue";
import IconRequest from "@/icons/IconRequest.vue";
import IconActivity from "@/icons/IconActivity.vue";
interface Stat {
key: string;
value: string | number;
label: string;
trend: number;
icon: any;
clickable: boolean;
sparkline?: number[];
}
const stats = ref({
totalUsers: 0,
activeTorrents: 0,
totalRequests: 0,
pendingRequests: 0,
approvedRequests: 0,
totalStorage: "0 GB",
usersTrend: 0,
torrentsTrend: 0,
requestsTrend: 0,
pendingTrend: 0,
approvedTrend: 0,
storageTrend: 0,
usersSparkline: [] as number[],
torrentsSparkline: [] as number[],
requestsSparkline: [] as number[]
});
const loading = ref(false);
const timeRange = ref("week");
const statCards = computed<Stat[]>(() => [
{
key: "totalUsers",
value: stats.value.totalUsers,
label: "Total Users",
trend: stats.value.usersTrend,
icon: IconProfile,
clickable: true,
sparkline: stats.value.usersSparkline
},
{
key: "activeTorrents",
value: stats.value.activeTorrents,
label: "Active Torrents",
trend: stats.value.torrentsTrend,
icon: IconPlay,
clickable: true,
sparkline: stats.value.torrentsSparkline
},
{
key: "totalRequests",
value: stats.value.totalRequests,
label: "Total Requests",
trend: stats.value.requestsTrend,
icon: IconRequest,
clickable: true,
sparkline: stats.value.requestsSparkline
},
{
key: "pendingRequests",
value: stats.value.pendingRequests,
label: "Pending Requests",
trend: stats.value.pendingTrend,
icon: IconRequest,
clickable: true
},
{
key: "approvedRequests",
value: stats.value.approvedRequests,
label: "Approved",
trend: stats.value.approvedTrend,
icon: IconRequest,
clickable: true
},
{
key: "totalStorage",
value: stats.value.totalStorage,
label: "Storage Used",
trend: stats.value.storageTrend,
icon: IconActivity,
clickable: false
}
]);
const generateSparkline = (
baseValue: number,
points: number = 7
): number[] => {
return Array.from({ length: points }, () => {
const variance = Math.random() * 0.3 - 0.15;
return Math.max(0, Math.floor(baseValue * (1 + variance)));
});
};
async function fetchStats() {
loading.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 500));
const baseUsers = 142;
const baseTorrents = 23;
const baseRequests = 856;
stats.value = {
totalUsers: baseUsers,
activeTorrents: baseTorrents,
totalRequests: baseRequests,
pendingRequests: 12,
approvedRequests: 712,
totalStorage: "2.4 TB",
usersTrend: 8.5,
torrentsTrend: -3.2,
requestsTrend: 12.7,
pendingTrend: -15.4,
approvedTrend: 18.2,
storageTrend: 5.8,
usersSparkline: generateSparkline(baseUsers / 7),
torrentsSparkline: generateSparkline(baseTorrents),
requestsSparkline: generateSparkline(baseRequests / 30)
};
} finally {
loading.value = false;
}
}
function handleCardClick(key: string) {
console.log(`Stat card clicked: ${key}`);
}
onMounted(() => fetchStats());
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.admin-stats {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
overflow: hidden;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
@include mobile-only {
margin-bottom: 0.6rem;
width: 100%;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
@include mobile-only {
font-size: 0.95rem;
}
}
&__controls {
display: flex;
gap: 0.5rem;
align-items: center;
@include mobile-only {
width: 100%;
justify-content: space-between;
}
}
&__loading {
padding: 2rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 0.6rem;
width: 100%;
}
}
}
.time-range-select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
cursor: pointer;
@include mobile-only {
flex: 1;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
}
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
}
}
}
.stat-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
text-align: center;
transition: all 0.2s;
overflow: hidden;
min-width: 0;
@include mobile-only {
padding: 0.6rem 0.4rem;
width: 100%;
box-sizing: border-box;
}
&--clickable {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background-color: var(--background-40);
}
@include mobile-only {
&:hover {
transform: none;
}
&:active {
transform: scale(0.98);
}
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 0.4rem;
@include mobile-only {
margin-bottom: 0.3rem;
}
}
&__icon {
width: 20px;
height: 20px;
fill: var(--highlight-color);
opacity: 0.8;
@include mobile-only {
width: 16px;
height: 16px;
}
}
&__trend {
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
@include mobile-only {
font-size: 0.65rem;
padding: 0.15rem 0.3rem;
}
&--up {
color: $white;
background-color: var(--color-success-highlight);
}
&--down {
color: $white;
background-color: var(--color-error-highlight);
}
}
&__value {
font-size: 2.2rem;
font-weight: 600;
color: var(--highlight-color);
margin-bottom: 0.15rem;
line-height: 1.1;
padding: 1rem 0;
@include mobile-only {
margin-bottom: 0.1rem;
}
}
&__label {
font-size: 0.8rem;
color: $text-color-70;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 0.4rem;
word-break: break-word;
max-width: 100%;
line-height: 1.2;
@include mobile-only {
margin-bottom: 0.3rem;
letter-spacing: 0.2px;
}
}
&__sparkline {
display: flex;
align-items: flex-end;
justify-content: space-between;
width: 100%;
height: 24px;
margin-top: 0.4rem;
gap: 2px;
@include mobile-only {
height: 18px;
margin-top: 0.3rem;
gap: 1px;
}
}
}
.sparkline-bar {
flex: 1;
background: linear-gradient(
180deg,
var(--highlight-color) 0%,
var(--color-green-70) 100%
);
border-radius: 2px 2px 0 0;
min-height: 3px;
transition: all 0.3s ease;
.stat-card:hover & {
opacity: 0.9;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,685 +0,0 @@
<template>
<div class="activity-feed">
<div class="activity-feed__header">
<h2 class="activity-feed__title">Recent Activity</h2>
<div class="activity-feed__controls">
<select v-model="typeFilter" class="activity-feed__filter">
<option value="">All Types</option>
<option value="request">Requests</option>
<option value="download">Downloads</option>
<option value="user">Users</option>
<option value="movie">Library</option>
</select>
<select
v-model="timeFilter"
class="activity-feed__filter"
@change="fetchActivities"
>
<option value="1h">Last Hour</option>
<option value="24h">Last 24 Hours</option>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
</select>
<button
class="refresh-btn"
@click="fetchActivities"
:disabled="loading"
>
<IconActivity :class="{ spin: loading }" />
</button>
</div>
</div>
<div v-if="loading" class="activity-feed__loading">
Loading activities...
</div>
<div v-else-if="error" class="activity-feed__error">{{ error }}</div>
<div v-else class="activity-feed__list">
<div
class="activity-item"
v-for="activity in filteredActivities"
:key="activity.id"
@click="handleActivityClick(activity)"
>
<div
:class="[
'activity-item__icon',
`activity-item__icon--${activity.type}`
]"
>
<component :is="getIcon(activity.type)" />
</div>
<div class="activity-item__content">
<div class="activity-item__header">
<span class="activity-item__message">{{ activity.message }}</span>
<span v-if="activity.metadata" class="activity-item__badge">
{{ activity.metadata }}
</span>
</div>
<div class="activity-item__footer">
<span class="activity-item__user" v-if="activity.user">{{
activity.user
}}</span>
<span class="activity-item__time">{{
formatTime(activity.timestamp)
}}</span>
</div>
</div>
</div>
<div v-if="filteredActivities.length === 0" class="activity-feed__empty">
No activities found
</div>
</div>
<div
v-if="!loading && filteredActivities.length > 0"
class="activity-feed__footer"
>
<span class="activity-count"
>{{ filteredActivities.length }} of
{{ activities.length }} activities</span
>
<button
v-if="hasMore"
class="load-more-btn"
@click="loadMore"
:disabled="loadingMore"
>
{{ loadingMore ? "Loading..." : "Load More" }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconPlay from "@/icons/IconPlay.vue";
import IconRequest from "@/icons/IconRequest.vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconActivity from "@/icons/IconActivity.vue";
type ActivityType = "request" | "download" | "user" | "movie";
interface Activity {
id: number;
type: ActivityType;
message: string;
timestamp: Date;
user?: string;
metadata?: string;
details?: any;
}
const activities = ref<Activity[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const error = ref("");
const typeFilter = ref<ActivityType | "">("");
const timeFilter = ref("24h");
const hasMore = ref(true);
const page = ref(1);
const filteredActivities = computed(() => {
let result = [...activities.value];
if (typeFilter.value) {
result = result.filter(a => a.type === typeFilter.value);
}
return result;
});
const getIcon = (type: string) => {
const icons: Record<string, any> = {
request: IconRequest,
download: IconPlay,
user: IconProfile,
movie: IconMovie
};
return icons[type] || IconMovie;
};
const formatTime = (date: Date): string => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
};
const generateMockActivities = (
count: number,
startId: number
): Activity[] => {
const types: ActivityType[] = ["request", "download", "user", "movie"];
const messages = {
request: [
"New request: Interstellar (2014)",
"Request approved: Oppenheimer",
"Request denied: The Matrix",
"Request fulfilled: Dune Part Two"
],
download: [
"Torrent completed: Dune Part Two",
"Torrent started: Poor Things",
"Download failed: Network Error",
"Torrent paused by admin"
],
user: [
"New user registered: john_doe",
"User upgraded to VIP: sarah_s",
"User login from new device: alex_p",
"Password changed: mike_r"
],
movie: [
"Movie added to library: The Batman",
"Library scan completed: 12 new items",
"Show updated: Breaking Bad S5",
"Media deleted: Old Movie (1999)"
]
};
const users = [
"admin",
"kevin_m",
"sarah_s",
"john_doe",
"alex_p",
"mike_r"
];
return Array.from({ length: count }, (_, i) => {
const type = types[Math.floor(Math.random() * types.length)];
const typeMessages = messages[type];
const message =
typeMessages[Math.floor(Math.random() * typeMessages.length)];
const timeOffset = Math.random() * 24 * 60 * 60 * 1000; // Random time in last 24h
return {
id: startId + i,
type,
message,
timestamp: new Date(Date.now() - timeOffset),
user: users[Math.floor(Math.random() * users.length)],
metadata: type === "request" ? "Pending" : undefined
};
});
};
async function fetchActivities() {
loading.value = true;
error.value = "";
page.value = 1;
try {
await new Promise(resolve => setTimeout(resolve, 500));
activities.value = generateMockActivities(15, 1).sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
hasMore.value = true;
} catch (e) {
error.value = "Failed to load activities";
} finally {
loading.value = false;
}
}
async function loadMore() {
if (!hasMore.value || loadingMore.value) return;
loadingMore.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 500));
const newActivities = generateMockActivities(
10,
activities.value.length + 1
);
activities.value = [...activities.value, ...newActivities].sort(
(a, b) => b.timestamp.getTime() - a.timestamp.getTime()
);
page.value += 1;
if (page.value >= 5) {
hasMore.value = false;
}
} finally {
loadingMore.value = false;
}
}
function handleActivityClick(activity: Activity) {
console.log("Activity clicked:", activity);
}
onMounted(fetchActivities);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.activity-feed {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
overflow: hidden;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.75rem;
@include mobile-only {
gap: 0.6rem;
margin-bottom: 0.6rem;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
@include mobile-only {
font-size: 0.95rem;
width: 100%;
}
}
&__controls {
display: flex;
gap: 0.5rem;
align-items: center;
@include mobile-only {
width: 100%;
gap: 0.4rem;
}
}
&__filter {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
cursor: pointer;
@include mobile-only {
flex: 1;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
min-width: 0;
max-width: calc(50% - 0.2rem - 20px);
}
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
&__loading,
&__error {
padding: 2rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
}
&__error {
color: var(--color-error-highlight);
}
&__list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 450px;
overflow-y: auto;
padding-right: 0.25rem;
@include mobile-only {
max-height: 350px;
gap: 0.35rem;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--background-40);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-color-50);
border-radius: 3px;
&:hover {
background: var(--text-color-70);
}
}
}
&__empty {
padding: 2rem;
text-align: center;
color: $text-color-50;
font-style: italic;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
}
&__footer {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--background-40);
display: flex;
justify-content: space-between;
align-items: center;
@include mobile-only {
margin-top: 0.75rem;
padding-top: 0.5rem;
flex-direction: column;
gap: 0.5rem;
align-items: stretch;
}
}
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.65rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
transition: background-color 0.2s;
cursor: pointer;
min-width: 0;
@include mobile-only {
gap: 0.6rem;
padding: 0.6rem;
width: 100%;
box-sizing: border-box;
}
&:hover {
background-color: var(--background-40);
}
@include mobile-only {
&:hover {
background-color: var(--background-ui);
}
&:active {
background-color: var(--background-40);
}
}
&__icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
@include mobile-only {
width: 26px;
height: 26px;
}
&--request {
background-color: #3b82f6;
}
&--download {
background-color: var(--highlight-color);
}
&--user {
background-color: #8b5cf6;
}
&--movie {
background-color: #f59e0b;
}
svg {
width: 14px;
height: 14px;
fill: $white;
@include mobile-only {
width: 13px;
height: 13px;
}
}
}
&__content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
@include mobile-only {
gap: 0.2rem;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.4rem;
}
&__message {
font-size: 0.85rem;
color: $text-color;
line-height: 1.3;
flex: 1;
word-break: break-word;
overflow-wrap: break-word;
@include mobile-only {
font-size: 0.78rem;
line-height: 1.25;
}
}
&__badge {
flex-shrink: 0;
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
background-color: var(--color-warning);
color: $black;
font-weight: 500;
text-transform: uppercase;
@include mobile-only {
font-size: 0.6rem;
padding: 0.1rem 0.3rem;
}
}
&__footer {
display: flex;
gap: 0.5rem;
align-items: center;
font-size: 0.75rem;
@include mobile-only {
font-size: 0.7rem;
gap: 0.35rem;
}
}
&__user {
color: $text-color-70;
font-weight: 500;
@include mobile-only {
font-size: 0.7rem;
}
&::before {
content: "@";
opacity: 0.7;
}
}
&__time {
color: $text-color-50;
@include mobile-only {
font-size: 0.7rem;
}
&::before {
content: "•";
margin-right: 0.5rem;
@include mobile-only {
margin-right: 0.3rem;
}
}
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
}
}
}
.load-more-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--background-40);
background-color: var(--background-ui);
color: $text-color;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
@include mobile-only {
width: 100%;
padding: 0.65rem 1rem;
font-size: 0.9rem;
}
&:hover:not(:disabled) {
background-color: var(--background-40);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.activity-count {
font-size: 0.8rem;
color: $text-color-50;
@include mobile-only {
font-size: 0.75rem;
text-align: center;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,750 +0,0 @@
<template>
<div class="system-status">
<div class="system-status__header">
<h2 class="system-status__title">System Status</h2>
<button
class="refresh-btn"
@click="fetchSystemStatus"
:disabled="loading"
>
<IconActivity :class="{ spin: loading }" />
</button>
</div>
<div v-if="loading" class="system-status__loading">
Loading system status...
</div>
<div v-else class="system-status__items">
<div
class="status-item"
v-for="item in systemItems"
:key="item.name"
@click="showDetails(item)"
>
<div class="status-item__header">
<span class="status-item__name">{{ item.name }}</span>
<div class="status-item__indicator-wrapper">
<span class="status-item__uptime" v-if="item.uptime">{{
item.uptime
}}</span>
<span
:class="[
'status-item__indicator',
`status-item__indicator--${item.status}`
]"
:title="`${item.status}`"
></span>
</div>
</div>
<div class="status-item__details">
<span class="status-item__value">{{ item.value }}</span>
<span class="status-item__description">{{ item.description }}</span>
</div>
<div v-if="item.metrics" class="status-item__metrics">
<div
v-for="metric in item.metrics"
:key="metric.label"
class="metric"
>
<span class="metric__label">{{ metric.label }}</span>
<div class="metric__bar">
<div
class="metric__fill"
:style="{ width: `${metric.value}%` }"
:class="getMetricClass(metric.value)"
></div>
</div>
<span class="metric__value">{{ metric.value }}%</span>
</div>
</div>
</div>
</div>
<!-- Details Modal -->
<div v-if="selectedItem" class="modal-overlay" @click="closeDetails">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>{{ selectedItem.name }} Details</h3>
<button class="close-btn" @click="closeDetails">
<IconClose />
</button>
</div>
<div class="modal-body">
<div class="detail-row">
<span class="detail-label">Status:</span>
<span
:class="['detail-value', `detail-value--${selectedItem.status}`]"
>
{{ selectedItem.status.toUpperCase() }}
</span>
</div>
<div class="detail-row">
<span class="detail-label">Current Value:</span>
<span class="detail-value">{{ selectedItem.value }}</span>
</div>
<div class="detail-row">
<span class="detail-label">Description:</span>
<span class="detail-value">{{ selectedItem.description }}</span>
</div>
<div v-if="selectedItem.uptime" class="detail-row">
<span class="detail-label">Uptime:</span>
<span class="detail-value">{{ selectedItem.uptime }}</span>
</div>
<div v-if="selectedItem.lastCheck" class="detail-row">
<span class="detail-label">Last Check:</span>
<span class="detail-value">{{ selectedItem.lastCheck }}</span>
</div>
<div v-if="selectedItem.logs" class="detail-logs">
<h4>Recent Logs</h4>
<div
class="log-entry"
v-for="(log, index) in selectedItem.logs"
:key="index"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
</div>
<div class="modal-footer">
<button class="action-btn" @click="restartService(selectedItem)">
Restart Service
</button>
<button
class="action-btn action-btn--secondary"
@click="viewFullLogs(selectedItem)"
>
View Full Logs
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
import IconClose from "@/icons/IconClose.vue";
interface Metric {
label: string;
value: number;
}
interface LogEntry {
time: string;
message: string;
}
interface SystemItem {
name: string;
status: "online" | "warning" | "offline";
value: string;
description: string;
uptime?: string;
lastCheck?: string;
metrics?: Metric[];
logs?: LogEntry[];
}
const systemItems = ref<SystemItem[]>([]);
const loading = ref(false);
const selectedItem = ref<SystemItem | null>(null);
const getMetricClass = (value: number) => {
if (value >= 90) return "metric__fill--critical";
if (value >= 70) return "metric__fill--warning";
return "metric__fill--good";
};
async function fetchSystemStatus() {
loading.value = true;
try {
await new Promise(resolve => setTimeout(resolve, 500));
systemItems.value = [
{
name: "API Server",
status: "online",
value: "Running",
description: "All endpoints responding",
uptime: "15d 7h 23m",
lastCheck: "Just now",
metrics: [
{ label: "CPU", value: 23 },
{ label: "Memory", value: 45 }
],
logs: [
{ time: "2m ago", message: "Health check passed" },
{ time: "5m ago", message: "Request handled: /api/v2/movie" },
{ time: "7m ago", message: "Cache hit: user_settings" }
]
},
{
name: "Disk Space",
status: "warning",
value: "45% Used",
description: "1.2 TB / 2.7 TB",
uptime: "15d 7h 23m",
lastCheck: "Just now",
metrics: [
{ label: "System", value: 45 },
{ label: "Media", value: 78 }
],
logs: [
{ time: "5m ago", message: "Disk usage check completed" },
{ time: "10m ago", message: "Media folder: 78% full" }
]
},
{
name: "Plex Connection",
status: "online",
value: "Connected",
description: "Server: Home",
uptime: "15d 7h 23m",
lastCheck: "Just now",
metrics: [{ label: "Response Time", value: 15 }],
logs: [
{ time: "2m ago", message: "Plex API request successful" },
{ time: "8m ago", message: "Library sync completed" }
]
}
];
} finally {
loading.value = false;
}
}
function showDetails(item: SystemItem) {
selectedItem.value = item;
}
function closeDetails() {
selectedItem.value = null;
}
function restartService(item: SystemItem) {
console.log(`Restarting service: ${item.name}`);
alert(`Restart initiated for ${item.name}`);
closeDetails();
}
function viewFullLogs(item: SystemItem) {
console.log(`Viewing full logs for: ${item.name}`);
alert(`Full logs for ${item.name} would open here`);
}
onMounted(fetchSystemStatus);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.system-status {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
overflow: hidden;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
@include mobile-only {
margin-bottom: 0.6rem;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
@include mobile-only {
font-size: 0.95rem;
}
}
&__loading {
padding: 1.5rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1rem;
font-size: 0.85rem;
}
}
&__items {
display: flex;
flex-direction: column;
gap: 0.6rem;
@include mobile-only {
gap: 0.5rem;
}
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
}
}
}
.status-item {
padding: 0.65rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
min-width: 0;
@include mobile-only {
padding: 0.6rem;
width: 100%;
box-sizing: border-box;
}
&:hover {
background-color: var(--background-40);
transform: translateX(2px);
}
@include mobile-only {
&:hover {
transform: none;
}
&:active {
background-color: var(--background-40);
transform: scale(0.98);
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.2rem;
}
&__name {
font-weight: 500;
color: $text-color;
font-size: 0.9rem;
line-height: 1.2;
@include mobile-only {
font-size: 0.82rem;
}
}
&__indicator-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
@include mobile-only {
gap: 0.35rem;
}
}
&__uptime {
font-size: 0.75rem;
color: $text-color-50;
@include mobile-only {
font-size: 0.7rem;
}
}
&__indicator {
width: 10px;
height: 10px;
border-radius: 50%;
animation: pulse 2s infinite;
@include mobile-only {
width: 8px;
height: 8px;
}
&--online {
background-color: var(--color-success-highlight);
box-shadow: 0 0 6px var(--color-success);
}
&--warning {
background-color: var(--color-warning-highlight);
box-shadow: 0 0 6px var(--color-warning);
}
&--offline {
background-color: var(--color-error-highlight);
box-shadow: 0 0 6px var(--color-error);
}
}
&__details {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
@include mobile-only {
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
margin-bottom: 0.3rem;
}
}
&__value {
font-size: 0.8rem;
color: $text-color-70;
line-height: 1.2;
@include mobile-only {
font-size: 0.75rem;
}
}
&__description {
font-size: 0.75rem;
color: $text-color-50;
line-height: 1.2;
@include mobile-only {
font-size: 0.7rem;
}
}
&__metrics {
margin-top: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
@include mobile-only {
margin-top: 0.3rem;
gap: 0.3rem;
}
}
}
.metric {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.7rem;
&__label {
min-width: 65px;
color: $text-color-70;
line-height: 1;
}
&__bar {
flex: 1;
height: 5px;
background-color: var(--background-40);
border-radius: 3px;
overflow: hidden;
}
&__fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
&--good {
background-color: var(--color-success-highlight);
}
&--warning {
background-color: var(--color-warning-highlight);
}
&--critical {
background-color: var(--color-error-highlight);
}
}
&__value {
min-width: 35px;
text-align: right;
color: $text-color-50;
}
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
@include mobile-only {
padding: 0.5rem;
align-items: flex-end;
}
}
.modal-content {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
max-width: 600px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
@include mobile-only {
max-height: 90vh;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--background-40);
@include mobile-only {
padding: 1rem;
}
h3 {
margin: 0;
color: $text-color;
font-weight: 400;
@include mobile-only {
font-size: 1rem;
}
}
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: background-color 0.2s;
&:hover {
background-color: var(--background-40);
}
svg {
width: 20px;
height: 20px;
fill: $text-color;
}
}
.modal-body {
padding: 1.5rem;
@include mobile-only {
padding: 1rem;
}
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid var(--background-40);
&:last-child {
border-bottom: none;
}
}
.detail-label {
font-weight: 500;
color: $text-color-70;
}
.detail-value {
color: $text-color;
&--online {
color: var(--color-success-highlight);
}
&--warning {
color: var(--color-warning-highlight);
}
&--offline {
color: var(--color-error-highlight);
}
}
.detail-logs {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--background-40);
h4 {
margin: 0 0 0.75rem 0;
color: $text-color;
font-weight: 400;
font-size: 0.95rem;
}
}
.log-entry {
display: flex;
gap: 1rem;
padding: 0.5rem;
font-size: 0.8rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.log-time {
min-width: 60px;
color: $text-color-50;
}
.log-message {
color: $text-color-70;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--background-40);
display: flex;
gap: 0.5rem;
justify-content: flex-end;
@include mobile-only {
padding: 1rem;
flex-direction: column-reverse;
}
}
.action-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--highlight-color);
background-color: var(--highlight-color);
color: $white;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
&:hover {
background-color: var(--color-green-90);
border-color: var(--color-green-90);
}
&--secondary {
background-color: transparent;
color: $text-color;
border-color: var(--background-40);
&:hover {
background-color: var(--background-ui);
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
</style>

View File

@@ -1,723 +0,0 @@
<template>
<div class="torrent-management">
<div class="torrent-management__header">
<h2 class="torrent-management__title">Torrent Management</h2>
<div class="torrent-management__controls">
<input
v-model="searchQuery"
type="text"
placeholder="Search torrents..."
class="torrent-management__search"
/>
<select v-model="statusFilter" class="torrent-management__filter">
<option value="">All Status</option>
<option value="seeding">Seeding</option>
<option value="downloading">Downloading</option>
<option value="paused">Paused</option>
<option value="stopped">Stopped</option>
</select>
<button class="refresh-btn" @click="fetchTorrents" :disabled="loading">
<IconActivity :class="{ spin: loading }" />
</button>
</div>
</div>
<div v-if="loading" class="torrent-management__loading">
Loading torrents...
</div>
<div v-else-if="error" class="torrent-management__error">{{ error }}</div>
<table v-else class="torrent-management__table">
<thead>
<tr>
<th @click="sortBy('name')" class="sortable">
Name
<span v-if="sortColumn === 'name'">{{
sortDirection === "asc" ? "" : ""
}}</span>
</th>
<th v-if="!isMobile" @click="sortBy('size')" class="sortable">
Size
<span v-if="sortColumn === 'size'">{{
sortDirection === "asc" ? "" : ""
}}</span>
</th>
<th v-if="!isMobile" @click="sortBy('seeders')" class="sortable">
Seeders
<span v-if="sortColumn === 'seeders'">{{
sortDirection === "asc" ? "" : ""
}}</span>
</th>
<th v-if="!isMobile">Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="torrent in filteredTorrents"
:key="torrent.id"
:class="{ processing: torrent.processing }"
>
<td class="torrent-name" :title="torrent.name">
<div class="torrent-name__title">{{ torrent.name }}</div>
<div v-if="isMobile" class="torrent-name__meta">
<span class="meta-item">{{ torrent.size }}</span>
<span class="meta-separator"></span>
<span class="meta-item">{{ torrent.seeders }} seeders</span>
<span class="meta-separator"></span>
<span
:class="['status-badge', `status-badge--${torrent.status}`]"
>
{{ torrent.status }}
</span>
</div>
</td>
<td v-if="!isMobile">{{ torrent.size }}</td>
<td v-if="!isMobile">{{ torrent.seeders }}</td>
<td v-if="!isMobile">
<span :class="['status-badge', `status-badge--${torrent.status}`]">
{{ torrent.status }}
</span>
</td>
<td class="actions">
<button
v-if="
torrent.status === 'seeding' || torrent.status === 'downloading'
"
class="action-btn"
title="Pause"
@click="pauseTorrent(torrent)"
:disabled="torrent.processing"
>
<IconStop />
</button>
<button
v-if="torrent.status === 'paused' || torrent.status === 'stopped'"
class="action-btn"
title="Resume"
@click="resumeTorrent(torrent)"
:disabled="torrent.processing"
>
<IconPlay />
</button>
<button
class="action-btn action-btn--danger"
title="Delete"
@click="deleteTorrent(torrent)"
:disabled="torrent.processing"
>
<IconClose />
</button>
<button
class="action-btn"
title="Details"
@click="showDetails(torrent)"
>
<IconInfo />
</button>
</td>
</tr>
</tbody>
</table>
<div class="torrent-management__footer">
<span class="torrent-count"
>{{ filteredTorrents.length }} of {{ torrents.length }} torrents</span
>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import IconStop from "@/icons/IconStop.vue";
import IconPlay from "@/icons/IconPlay.vue";
import IconClose from "@/icons/IconClose.vue";
import IconInfo from "@/icons/IconInfo.vue";
import IconActivity from "@/icons/IconActivity.vue";
interface Torrent {
id: number;
name: string;
size: string;
seeders: number;
leechers: number;
uploaded: string;
downloaded: string;
ratio: number;
status: "seeding" | "downloading" | "paused" | "stopped";
processing?: boolean;
}
const torrents = ref<Torrent[]>([]);
const loading = ref(false);
const error = ref("");
const searchQuery = ref("");
const statusFilter = ref("");
const sortColumn = ref<keyof Torrent>("name");
const sortDirection = ref<"asc" | "desc">("asc");
const windowWidth = ref(window.innerWidth);
const isMobile = computed(() => windowWidth.value <= 768);
function handleResize() {
windowWidth.value = window.innerWidth;
}
const filteredTorrents = computed(() => {
let result = [...torrents.value];
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(t => t.name.toLowerCase().includes(query));
}
if (statusFilter.value) {
result = result.filter(t => t.status === statusFilter.value);
}
result.sort((a, b) => {
const aVal = a[sortColumn.value];
const bVal = b[sortColumn.value];
if (typeof aVal === "string" && typeof bVal === "string") {
return sortDirection.value === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
if (typeof aVal === "number" && typeof bVal === "number") {
return sortDirection.value === "asc" ? aVal - bVal : bVal - aVal;
}
return 0;
});
return result;
});
function sortBy(column: keyof Torrent) {
if (sortColumn.value === column) {
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
} else {
sortColumn.value = column;
sortDirection.value = "asc";
}
}
async function fetchTorrents() {
loading.value = true;
error.value = "";
try {
await new Promise(resolve => setTimeout(resolve, 500));
torrents.value = [
{
id: 1,
name: "Movie.Name.2024.1080p.BluRay.x264",
size: "2.4 GB",
seeders: 156,
leechers: 23,
uploaded: "45.2 GB",
downloaded: "2.4 GB",
ratio: 18.83,
status: "seeding"
},
{
id: 2,
name: "TV.Show.S01E01.720p.WEB-DL",
size: "1.2 GB",
seeders: 89,
leechers: 12,
uploaded: "12.8 GB",
downloaded: "1.2 GB",
ratio: 10.67,
status: "seeding"
},
{
id: 3,
name: "Documentary.2024.HDRip",
size: "890 MB",
seeders: 45,
leechers: 8,
uploaded: "2.1 GB",
downloaded: "650 MB",
ratio: 3.31,
status: "downloading"
},
{
id: 4,
name: "Anime.Series.S02E10.1080p",
size: "1.8 GB",
seeders: 234,
leechers: 56,
uploaded: "89.4 GB",
downloaded: "1.8 GB",
ratio: 49.67,
status: "seeding"
},
{
id: 5,
name: "Concert.2024.4K.UHD",
size: "12.5 GB",
seeders: 67,
leechers: 5,
uploaded: "0 B",
downloaded: "0 B",
ratio: 0,
status: "paused"
},
{
id: 6,
name: "Drama.Series.2024.S01E05.1080p",
size: "2.1 GB",
seeders: 112,
leechers: 34,
uploaded: "8.9 GB",
downloaded: "2.1 GB",
ratio: 4.24,
status: "seeding"
},
{
id: 7,
name: "Action.Movie.2024.BRRip",
size: "1.5 GB",
seeders: 0,
leechers: 0,
uploaded: "0 B",
downloaded: "0 B",
ratio: 0,
status: "stopped"
}
];
} catch (e) {
error.value = "Failed to load torrents";
} finally {
loading.value = false;
}
}
async function pauseTorrent(torrent: Torrent) {
torrent.processing = true;
await new Promise(resolve => setTimeout(resolve, 500));
torrent.status = "paused";
torrent.processing = false;
}
async function resumeTorrent(torrent: Torrent) {
torrent.processing = true;
await new Promise(resolve => setTimeout(resolve, 500));
torrent.status = "seeding";
torrent.processing = false;
}
async function deleteTorrent(torrent: Torrent) {
if (!confirm(`Are you sure you want to delete "${torrent.name}"?`)) return;
torrent.processing = true;
await new Promise(resolve => setTimeout(resolve, 500));
torrents.value = torrents.value.filter(t => t.id !== torrent.id);
}
function showDetails(torrent: Torrent) {
alert(
`Torrent Details:\n\nName: ${torrent.name}\nSize: ${torrent.size}\nSeeders: ${torrent.seeders}\nLeechers: ${torrent.leechers}\nUploaded: ${torrent.uploaded}\nDownloaded: ${torrent.downloaded}\nRatio: ${torrent.ratio.toFixed(2)}\nStatus: ${torrent.status}`
);
}
onMounted(() => {
fetchTorrents();
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
window.removeEventListener("resize", handleResize);
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.torrent-management {
background-color: var(--background-color-secondary);
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
max-width: 100%;
@include mobile-only {
background-color: transparent;
border-radius: 0;
padding: 0;
box-shadow: none;
width: 100%;
overflow-x: auto;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
flex-wrap: wrap;
gap: 0.75rem;
@include mobile-only {
gap: 0.6rem;
margin-bottom: 0.6rem;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 400;
color: $text-color;
text-transform: uppercase;
letter-spacing: 0.8px;
@include mobile-only {
font-size: 0.95rem;
width: 100%;
}
}
&__controls {
display: flex;
gap: 0.5rem;
align-items: center;
@include mobile-only {
width: 100%;
gap: 0.4rem;
}
}
&__search {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
@include mobile-only {
flex: 1;
min-width: 0;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
max-width: calc(50% - 0.2rem - 20px);
}
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
&__filter {
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
background-color: var(--background-color);
color: $text-color;
font-size: 0.85rem;
cursor: pointer;
@include mobile-only {
flex: 1;
font-size: 0.8rem;
padding: 0.4rem 0.5rem;
max-width: calc(50% - 0.2rem - 20px);
}
&:focus {
outline: none;
border-color: var(--highlight-color);
}
}
&__loading,
&__error {
padding: 2rem;
text-align: center;
color: $text-color-70;
@include mobile-only {
padding: 1.5rem;
font-size: 0.9rem;
}
}
&__error {
color: var(--color-error-highlight);
}
&__footer {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--background-40);
@include mobile-only {
margin-top: 0.75rem;
padding-top: 0.5rem;
}
}
&__table {
width: 100%;
max-width: 100%;
border-spacing: 0;
border-radius: 0.5rem;
overflow: hidden;
table-layout: fixed;
@include mobile-only {
table-layout: auto;
}
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--background-40);
@include mobile-only {
padding: 0.5rem 0.4rem;
font-size: 0.75rem;
}
}
th {
background-color: var(--table-background-color);
color: var(--table-header-text-color);
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.5px;
font-weight: 400;
@include mobile-only {
font-size: 0.7rem;
letter-spacing: 0.3px;
white-space: nowrap;
}
&.sortable {
cursor: pointer;
user-select: none;
&:hover {
background-color: var(--background-80);
}
}
}
td {
font-size: 0.85rem;
color: $text-color;
@include mobile-only {
font-size: 0.75rem;
white-space: nowrap;
}
}
tbody tr {
background-color: var(--background-color);
transition: background-color 0.2s;
&:nth-child(even) {
background-color: var(--background-70);
}
&:hover {
background-color: var(--background-ui);
}
&.processing {
opacity: 0.6;
pointer-events: none;
}
}
}
}
.torrent-name {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@include mobile-only {
max-width: none;
white-space: normal;
overflow: visible;
}
&__title {
word-break: break-word;
overflow-wrap: break-word;
@include mobile-only {
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
}
&__meta {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
font-size: 0.7rem;
color: var(--text-color-60);
margin-top: 0.25rem;
.meta-item {
white-space: nowrap;
}
.meta-separator {
color: var(--text-color-40);
}
.status-badge {
margin: 0;
}
}
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 500;
@include mobile-only {
font-size: 0.6rem;
padding: 0.2rem 0.35rem;
}
&--seeding {
background-color: var(--color-success);
color: var(--color-success-text);
}
&--downloading {
background-color: var(--color-warning);
color: $black;
}
&--paused {
background-color: var(--background-40);
color: $text-color-70;
}
&--stopped {
background-color: var(--color-error);
color: $white;
}
}
.actions {
display: flex;
gap: 0.25rem;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.35rem;
border-radius: 0.25rem;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
&:hover:not(:disabled) {
background-color: var(--background-40);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--danger:hover:not(:disabled) {
background-color: var(--color-error);
svg {
fill: $white;
}
}
svg {
width: 16px;
height: 16px;
fill: $text-color;
}
}
.refresh-btn {
background: none;
border: 1px solid var(--background-40);
cursor: pointer;
padding: 0.5rem;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
@include mobile-only {
width: 40px;
padding: 0.4rem;
}
&:hover:not(:disabled) {
background-color: var(--background-ui);
border-color: var(--highlight-color);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg {
width: 18px;
height: 18px;
fill: $text-color;
@include mobile-only {
width: 16px;
height: 16px;
}
&.spin {
animation: spin 1s linear infinite;
}
}
}
.torrent-count {
font-size: 0.8rem;
color: $text-color-50;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,276 +0,0 @@
<template>
<transition name="shut">
<ul class="dropdown">
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<li
v-for="(result, _index) in searchResults"
:key="`${_index}-${result.title}-${result.type}`"
:class="`result di-${_index} ${_index === index ? 'active' : ''}`"
@click="openPopup(result)"
>
<IconMovie v-if="result.type == 'movie'" class="type-icon" />
<IconShow v-if="result.type == 'show'" class="type-icon" />
<span class="title">{{ result.title }}</span>
</li>
<li
v-if="searchResults.length"
:class="`info di-${searchResults.length}`"
>
<span> Select from list or press enter to search </span>
</li>
</ul>
</transition>
</template>
<script setup lang="ts">
import type { Ref } from "vue";
import { ref, watch, defineProps } from "vue";
import { useStore } from "vuex";
import IconMovie from "../../icons/IconMovie.vue";
import IconShow from "../../icons/IconShow.vue";
import { elasticSearchMoviesAndShows } from "../../api";
import { MediaTypes } from "../../interfaces/IList";
import type {
IAutocompleteResult,
IAutocompleteSearchResults
} from "../../interfaces/IAutocompleteSearch";
interface Props {
query?: string;
index?: number;
results?: Array<IAutocompleteResult>;
}
interface Emit {
(e: "update:results", value: Array<IAutocompleteResult>);
}
const numberOfResults = 10;
const props = defineProps<Props>();
const emit = defineEmits<Emit>();
const store = useStore();
const searchResults: Ref<Array<IAutocompleteResult>> = ref([]);
const keyboardNavigationIndex: Ref<number> = ref(0);
let disableOnFailure = false;
watch(
() => props.query,
newQuery => {
if (newQuery?.length > 0 && !disableOnFailure)
fetchAutocompleteResults(); /* eslint-disable-line no-use-before-define */
}
);
function openPopup(result: IAutocompleteResult) {
if (!result.id || !result.type) return;
store.dispatch("popup/open", { ...result });
}
function removeDuplicates(_searchResults: Array<IAutocompleteResult>) {
const filteredResults = [];
_searchResults.forEach(result => {
if (result === undefined) return;
const numberOfDuplicates = filteredResults.filter(
filterItem => filterItem.id === result.id
);
if (numberOfDuplicates.length >= 1) {
return;
}
filteredResults.push(result);
});
return filteredResults;
}
function elasticTypeToMediaType(type: string): MediaTypes {
if (type === "movie") return MediaTypes.Movie;
if (type === "tv_series") return MediaTypes.Show;
return null;
}
function parseElasticResponse(elasticResponse: IAutocompleteSearchResults) {
const { hits } = elasticResponse.hits;
const data = hits.length > 0 ? hits : (searchResults.value ?? []);
const results: Array<IAutocompleteResult> = [];
data.forEach(item => {
if (!item._index) return;
results.push({
title: item._source?.original_name || item._source.original_title,
id: item._source.id,
adult: item._source.adult,
type: elasticTypeToMediaType(item._source.type)
});
});
return removeDuplicates(results).map((el, index) => {
return { ...el, index };
});
}
async function fetchAutocompleteResults() {
keyboardNavigationIndex.value = 0;
searchResults.value = [];
return elasticSearchMoviesAndShows(props.query, numberOfResults)
.catch(error => {
// TODO display error
disableOnFailure = true;
throw error;
})
.then(elasticResponse => parseElasticResponse(elasticResponse))
.then(_searchResults => {
emit("update:results", _searchResults);
searchResults.value = _searchResults;
})
.catch(error => {
// TODO display error
disableOnFailure = true;
throw error;
});
}
// on load functions
fetchAutocompleteResults();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
$sizes: 22;
@for $i from 0 through $sizes {
.dropdown .di-#{$i} {
visibility: visible;
transform-origin: top center;
animation: scaleZ 200ms calc(50ms * #{$i}) ease-in forwards;
}
}
@keyframes scaleZ {
0% {
opacity: 0;
transform: scale(0);
}
80% {
transform: scale(1.07);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.dropdown {
top: var(--header-size);
position: relative;
height: 100%;
width: 100%;
max-width: 720px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
z-index: 5;
margin-top: -1px;
border-top: none;
padding: 0;
@include mobile {
position: fixed;
left: 0;
max-width: 100vw;
}
@include tablet-min {
top: unset;
--gutter: 1.5rem;
max-width: calc(100% - (2 * var(--gutter)));
margin: -1px var(--gutter) 0 var(--gutter);
}
@include desktop {
max-width: 720px;
}
}
li.result {
background-color: var(--background-95);
color: var(--text-color-50);
padding: 0.5rem 2rem;
list-style: none;
opacity: 0;
height: 56px;
width: 100%;
visibility: hidden;
display: flex;
align-items: center;
padding: 0.5rem 2rem;
font-size: 1.4rem;
text-transform: capitalize;
list-style: none;
cursor: pointer;
white-space: nowrap;
transition:
color 0.1s ease,
fill 0.4s ease;
span {
overflow-x: hidden;
text-overflow: ellipsis;
transition: inherit;
}
&.active,
&:hover,
&:active {
color: var(--text-color);
border-bottom: 2px solid var(--color-green);
.type-icon {
fill: var(--text-color);
}
}
.type-icon {
width: 28px;
height: 28px;
margin-right: 1rem;
transition: inherit;
fill: var(--text-color-50);
}
}
li.info {
visibility: hidden;
opacity: 0;
display: flex;
justify-content: center;
padding: 0 1rem;
color: var(--text-color-50);
background-color: var(--background-95);
color: var(--text-color-50);
font-size: 0.6rem;
height: 16px;
width: 100%;
}
.shut-leave-to {
height: 0px;
transition: height 0.4s ease;
flex-wrap: no-wrap;
overflow: hidden;
}
</style>

View File

@@ -1,125 +0,0 @@
<template>
<nav>
<!-- eslint-disable-next-line vuejs-accessibility/anchor-has-content -->
<a v-if="isHome" class="nav__logo" href="/">
<TmdbLogo class="logo" />
</a>
<router-link v-else class="nav__logo" to="/" exact>
<TmdbLogo class="logo" />
</router-link>
<SearchInput />
<Hamburger class="mobile-only" />
<NavigationIcon class="desktop-only" :route="profileRoute" />
<!-- <div class="navigation-icons-grid mobile-only" :class="{ open: isOpen }"> -->
<div v-if="isOpen" class="navigation-icons-grid mobile-only">
<NavigationIcons>
<NavigationIcon :route="profileRoute" />
</NavigationIcons>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useStore } from "vuex";
import { useRoute } from "vue-router";
import SearchInput from "@/components/header/SearchInput.vue";
import Hamburger from "@/components/ui/Hamburger.vue";
import NavigationIcons from "@/components/header/NavigationIcons.vue";
import NavigationIcon from "@/components/header/NavigationIcon.vue";
import TmdbLogo from "@/icons/tmdb-logo.vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconProfileLock from "@/icons/IconProfileLock.vue";
import type INavigationIcon from "../../interfaces/INavigationIcon";
const route = useRoute();
const store = useStore();
const signinNavigationIcon: INavigationIcon = {
title: "Signin",
route: "/login",
icon: IconProfileLock
};
const profileNavigationIcon: INavigationIcon = {
title: "Profile",
route: "/profile",
icon: IconProfile
};
const isHome = computed(() => route.path === "/");
const isOpen = computed(() => store.getters["hamburger/isOpen"]);
const loggedIn = computed(() => store.getters["user/loggedIn"]);
const profileRoute = computed(() =>
!loggedIn.value ? signinNavigationIcon : profileNavigationIcon
);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.spacer {
@include mobile-only {
width: 100%;
height: $header-size;
}
}
nav {
display: grid;
grid-template-columns: var(--header-size) 1fr var(--header-size);
> * {
z-index: 10;
}
}
.nav__logo {
overflow: hidden;
}
.logo {
padding: 1rem;
fill: var(--color-green);
width: var(--header-size);
height: var(--header-size);
display: flex;
align-items: center;
justify-content: center;
background: $background-nav-logo;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.08);
}
@include mobile {
padding: 0.5rem;
}
}
.navigation-icons-grid {
display: flex;
flex-wrap: wrap;
position: fixed;
top: var(--header-size);
left: 0;
width: 100%;
background-color: $background-95;
visibility: hidden;
opacity: 0;
transition: opacity 0.4s ease;
opacity: 1;
visibility: visible;
&.open {
}
}
</style>

View File

@@ -1,102 +0,0 @@
<template>
<router-link
v-if="route?.requiresAuth == undefined || (route?.requiresAuth && loggedIn)"
:key="route?.title"
:to="{ path: route?.route }"
>
<li
class="navigation-link"
:class="{ active: matchesActiveRoute(), 'nofill-stroke': useStroke }"
>
<component :is="route.icon" class="navigation-icon"></component>
<span>{{ route.title }}</span>
</li>
</router-link>
</template>
<script setup lang="ts">
import { useStore } from "vuex";
import { computed } from "vue";
import type INavigationIcon from "../../interfaces/INavigationIcon";
interface Props {
route: INavigationIcon;
active?: string;
useStroke?: boolean;
}
const props = defineProps<Props>();
const store = useStore();
const loggedIn = computed(() => store.getters["user/loggedIn"]);
function matchesActiveRoute() {
const currentRoute = props.route.title.toLowerCase();
return props?.active?.includes(currentRoute);
}
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.navigation-link {
display: grid;
place-items: center;
min-height: var(--header-size);
list-style: none;
padding: 1rem 0.15rem;
text-align: center;
background-color: var(--background-color-secondary);
transition:
transform 0.3s ease,
color 0.3s ease,
stoke 0.3s ease,
fill 0.3s ease,
background-color 0.5s ease;
transition: all 0.3s ease;
&:hover {
transform: scale(1.05);
}
&:hover,
&.active {
background-color: var(--background-color);
span,
.navigation-icon {
color: var(--text-color);
fill: var(--text-color);
}
}
span {
text-transform: uppercase;
font-size: 11px;
margin-top: 0.25rem;
color: var(--text-color-70);
}
&.nofill-stroke {
.navigation-icon {
stroke: var(--text-color-70);
fill: none !important;
}
&:hover .navigation-icon,
&.active .navigation-icon {
stroke: var(--text-color);
}
}
}
a {
text-decoration: none;
}
.navigation-icon {
width: 28px;
fill: var(--text-color-70);
transition: inherit;
}
</style>

View File

@@ -1,99 +0,0 @@
<template>
<ul class="navigation-icons">
<NavigationIcon
v-for="_route in routes"
:key="_route.route"
:route="_route"
:active="activeRoute"
:use-stroke="_route?.useStroke"
/>
<slot></slot>
</ul>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
import NavigationIcon from "@/components/header/NavigationIcon.vue";
import IconInbox from "@/icons/IconInbox.vue";
import IconNowPlaying from "@/icons/IconNowPlaying.vue";
import IconPopular from "@/icons/IconPopular.vue";
import IconUpcoming from "@/icons/IconUpcoming.vue";
import IconSettings from "@/icons/IconSettings.vue";
import IconActivity from "@/icons/IconActivity.vue";
import IconBinoculars from "@/icons/IconBinoculars.vue";
import type INavigationIcon from "../../interfaces/INavigationIcon";
const route = useRoute();
const activeRoute = ref(window?.location?.pathname);
const routes: INavigationIcon[] = [
{
title: "Requests",
route: "/list/requests",
icon: IconInbox
},
{
title: "Now Playing",
route: "/list/now_playing",
icon: IconNowPlaying
},
{
title: "Popular",
route: "/list/popular",
icon: IconPopular
},
{
title: "Upcoming",
route: "/list/upcoming",
icon: IconUpcoming
},
{
title: "Activity",
route: "/activity",
requiresAuth: true,
useStroke: true,
icon: IconActivity
},
{
title: "Torrents",
route: "/torrents",
requiresAuth: true,
icon: IconBinoculars
},
{
title: "Settings",
route: "/settings",
requiresAuth: true,
useStroke: true,
icon: IconSettings
}
];
function setActiveRoute(_route: string) {
activeRoute.value = _route;
}
watch(route, () => setActiveRoute(window?.location?.pathname || ""));
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.navigation-icons {
display: grid;
grid-column: 1fr;
padding-left: 0;
margin: 0;
background-color: var(--background-color-secondary);
z-index: 15;
width: 100%;
@include desktop {
grid-template-rows: var(--header-size);
}
@include mobile {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -1,297 +0,0 @@
<template>
<div>
<div class="search" :class="{ active: inputIsActive }">
<IconSearch class="search-icon" tabindex="-1" />
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
<input
ref="inputElement"
v-model="query"
type="text"
placeholder="Search for movie or show"
aria-label="Search input for finding a movie or show"
autocorrect="off"
autocapitalize="off"
tabindex="0"
@input="handleInput"
@click="focus"
@keydown.escape="handleEscape"
@keyup.enter="handleSubmit"
@keydown.up="navigateUp"
@keydown.down="navigateDown"
@focus="focus"
@blur="blur"
/>
<IconClose
v-if="query && query.length"
tabindex="0"
aria-label="button"
class="close-icon"
@click="clearInput"
@keydown.enter.stop="clearInput"
/>
</div>
<AutocompleteDropdown
v-if="showAutocompleteResults"
v-model:results="dropdownResults"
:query="query"
:index="dropdownIndex"
/>
</div>
</template>
<!-- Handles constructing markup and state for dropdown.
Markup:
Consist of: search icon, input & close button.
State:
State is passing input variable `query` to dropdown and carrying state
of selected dropdown element as variable `index`. This is because
index is manipulated based on arrow key events from same input as
the `query`.
-->
<script setup lang="ts">
import type { Ref } from "vue";
import { ref, computed } from "vue";
import { useStore } from "vuex";
import { useRouter, useRoute } from "vue-router";
import AutocompleteDropdown from "./AutocompleteDropdown.vue";
import IconSearch from "../../icons/IconSearch.vue";
import IconClose from "../../icons/IconClose.vue";
import { IAutocompleteResult } from "../../interfaces/IAutocompleteSearch";
const store = useStore();
const router = useRouter();
const route = useRoute();
const query: Ref<string> = ref(null);
const disabled: Ref<boolean> = ref(false);
const dropdownIndex: Ref<number> = ref(-1);
const dropdownResults: Ref<IAutocompleteResult[]> = ref([]);
const inputIsActive: Ref<boolean> = ref(false);
const inputElement: Ref<HTMLInputElement> = ref(null);
const isOpen = computed(() => store.getters["popup/isOpen"]);
const showAutocompleteResults = computed(() => {
return (
!disabled.value &&
inputIsActive.value &&
query.value &&
query.value.length > 0
);
});
const params = new URLSearchParams(window.location.search);
if (params && params.has("query")) {
query.value = decodeURIComponent(params.get("query"));
}
const ELASTIC_URL = import.meta.env.VITE_ELASTIC_URL;
const ELASTIC_API_KEY = import.meta.env.VITE_ELASTIC_API_KEY;
if (!ELASTIC_URL || !ELASTIC_API_KEY) {
disabled.value = true;
}
function navigateDown() {
if (dropdownIndex.value < dropdownResults.value.length - 1) {
dropdownIndex.value += 1;
}
}
function navigateUp() {
if (dropdownIndex.value > -1) dropdownIndex.value -= 1;
const textLength = inputElement.value.value.length;
setTimeout(() => {
inputElement.value.focus();
inputElement.value.setSelectionRange(textLength, textLength + 1);
}, 1);
}
function search() {
const encodedQuery = encodeURI(query.value.replace("/ /g", "+"));
router.push({
name: "search",
query: {
...route.query,
query: encodedQuery
}
});
}
function handleInput() {
dropdownIndex.value = -1;
}
function focus() {
inputIsActive.value = true;
}
function reset() {
inputElement.value.blur();
dropdownIndex.value = -1;
inputIsActive.value = false;
}
function blur() {
return setTimeout(reset, 150);
}
function clearInput() {
query.value = "";
inputElement.value.focus();
}
function handleSubmit() {
if (!query.value || query.value.length === 0) return;
// if index is set, navigation has happened. Open popup else search
if (dropdownIndex.value >= 0) {
const resultItem = dropdownResults.value[dropdownIndex.value];
store.dispatch("popup/open", {
id: resultItem?.id,
type: resultItem?.type
});
return;
}
search();
reset();
}
function handleEscape() {
if (!isOpen.value) reset();
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
.close-icon {
position: absolute;
top: calc(50% - 12px);
right: 0;
cursor: pointer;
fill: var(--text-color);
height: 24px;
width: 24px;
@include tablet-min {
right: 6px;
}
}
.filter {
width: 100%;
display: flex;
flex-direction: column;
margin: 1rem 2rem;
h2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 400;
}
&-items {
display: flex;
flex-direction: row;
align-items: center;
> :not(:first-child) {
margin-left: 1rem;
}
}
}
hr {
display: block;
height: 1px;
border: 0;
border-bottom: 1px solid $text-color-50;
margin-top: 10px;
margin-bottom: 10px;
width: 90%;
}
.search.active {
input {
border-color: var(--color-green);
}
.search-icon {
fill: var(--color-green);
}
}
.search {
height: $header-size;
display: flex;
position: fixed;
flex-wrap: wrap;
z-index: 5;
border: 0;
background-color: $background-color-secondary;
// TODO check if this is for mobile
width: calc(100% - 110px);
top: 0;
right: 55px;
@include tablet-min {
position: relative;
width: 100%;
right: 0px;
}
input {
display: block;
width: 100%;
padding: 13px 28px 13px 45px;
outline: none;
margin: 0;
border: 0;
background-color: $background-color-secondary;
font-weight: 300;
font-size: 18px;
color: $text-color;
border-bottom: 1px solid transparent;
&:focus {
// border-bottom: 1px solid var(--color-green);
border-color: var(--color-green);
}
@include tablet-min {
font-size: 24px;
padding: 13px 40px 13px 60px;
}
}
&-icon {
width: 20px;
height: 20px;
fill: var(--text-color-50);
pointer-events: none;
position: absolute;
left: 15px;
top: calc(50% - 10px);
@include tablet-min {
width: 24px;
height: 24px;
top: calc(50% - 12px);
left: 22px;
}
}
}
</style>

View File

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

@@ -1,143 +0,0 @@
<template>
<div class="plex-connect">
<div class="info-box">
<IconInfo class="info-icon" />
<p>
Sign in to your Plex account to get information about recently added
movies and to see your watch history
</p>
</div>
<div class="signin-container">
<button @click="handleAuth" :disabled="loading" class="plex-signin-btn">
{{ loading ? "Connecting..." : "Sign in with Plex" }}
<IconPlex v-if="!loading" class="plex-icon" />
</button>
<p class="popup-note">A popup window will open for authentication</p>
</div>
</div>
</template>
<script setup lang="ts">
import { usePlexAuth } from "@/composables/usePlexAuth";
import IconInfo from "@/icons/IconInfo.vue";
import IconPlex from "@/icons/IconPlex.vue";
const emit = defineEmits<{
authSuccess: [token: string];
authError: [message: string];
}>();
const { loading, openAuthPopup } = usePlexAuth();
function handleAuth() {
openAuthPopup(
token => emit("authSuccess", token),
error => emit("authError", error)
);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.info-box {
display: flex;
gap: 0.65rem;
padding: 0.65rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
margin-bottom: 0.85rem;
border-left: 3px solid var(--highlight-color);
@include mobile-only {
padding: 0.6rem;
gap: 0.55rem;
margin-bottom: 0.7rem;
}
p {
margin: 0;
font-size: 0.9rem;
line-height: 1.4;
@include mobile-only {
font-size: 0.85rem;
}
}
}
.info-icon {
width: 20px;
height: 20px;
fill: var(--highlight-color);
flex-shrink: 0;
@include mobile-only {
width: 18px;
height: 18px;
}
}
.signin-container {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.plex-signin-btn {
padding: 1rem 1.75rem;
background-color: #c87818;
color: $white;
border: none;
border-radius: 0.75rem;
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
align-self: flex-start;
box-shadow: 0 4px 12px rgba(200, 120, 24, 0.25);
letter-spacing: -0.01em;
@include mobile-only {
width: 100%;
padding: 0.9rem 1.4rem;
font-size: 1rem;
}
&:hover:not(:disabled) {
background-color: #b36a15;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(200, 120, 24, 0.4);
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 4px 12px rgba(200, 120, 24, 0.3);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.plex-icon {
flex-shrink: 0;
--size: 24px;
width: var(--size);
height: var(--size);
fill: currentColor;
}
}
.popup-note {
margin: 0;
font-size: 0.85rem;
opacity: 0.65;
}
</style>

View File

@@ -1,230 +0,0 @@
<template>
<a
v-if="item.plexUrl"
:href="item.plexUrl"
target="_blank"
rel="noopener noreferrer"
class="plex-library-item"
>
<figure :class="`item-poster ${item.type}`">
<img
v-if="item.poster"
:src="item.poster"
:alt="item.title"
class="poster-image"
@error="handleImageError"
/>
<div v-else class="poster-fallback">
<component :is="fallbackIconComponent" />
</div>
</figure>
<div class="item-details">
<p class="item-title">{{ item.title }}</p>
<div class="item-meta">
<span v-if="item.year" class="item-year">{{ item.year }}</span>
<span v-if="item.rating" class="item-rating">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
{{ item.rating }}
</span>
</div>
<div v-if="showExtras" class="item-extras">
<span v-if="item.artist">{{ item.artist }}</span>
<span v-if="item.episodes">{{ item.episodes }} episodes</span>
<span v-if="item.tracks">{{ item.tracks }} tracks</span>
</div>
</div>
</a>
<div v-else class="plex-library-item plex-library-item--no-link">
<figure class="item-poster">
<img
v-if="item.poster"
:src="item.poster"
:alt="item.title"
class="poster-image"
@error="handleImageError"
/>
<div v-else class="poster-fallback">
<component :is="fallbackIconComponent" />
</div>
</figure>
<div class="item-details">
<p class="item-title">{{ item.title }}</p>
<div class="item-meta">
<span v-if="item.year" class="item-year">{{ item.year }}</span>
<span v-if="item.rating" class="item-rating">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
{{ item.rating }}
</span>
</div>
<div v-if="showExtras" class="item-extras">
<span v-if="item.artist">{{ item.artist }}</span>
<span v-if="item.episodes">{{ item.episodes }} episodes</span>
<span v-if="item.tracks">{{ item.tracks }} tracks</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
interface LibraryItem {
title: string;
poster?: string;
fallbackIcon?: string;
year?: number;
rating?: number;
artist?: string;
episodes?: number;
tracks?: number;
plexUrl?: string | null;
}
interface Props {
item: LibraryItem;
showExtras?: boolean;
}
const props = defineProps<Props>();
const fallbackIconComponent = computed(() => {
if (props.item.fallbackIcon === "🎬") return IconMovie;
if (props.item.fallbackIcon === "📺") return IconShow;
if (props.item.fallbackIcon === "🎵") return IconMusic;
return IconMovie; // Default fallback
});
function handleImageError(event: Event) {
const target = event.target as HTMLImageElement;
target.style.display = "none";
}
</script>
<style style="scss" scoped>
.plex-library-item {
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
transition: transform 0.2s;
text-decoration: none;
color: inherit;
}
.plex-library-item:hover {
transform: translateY(-4px);
}
.plex-library-item--no-link {
cursor: default;
}
.plex-library-item--no-link:hover {
transform: none;
}
.item-poster {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 8px;
overflow: hidden;
background: #333;
margin: 0;
&.music {
aspect-ratio: 1/1;
}
}
.poster-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.poster-fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #333 0%, #222 100%);
padding: 20%;
svg {
width: 100%;
height: 100%;
fill: #666;
}
}
.item-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.item-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #fff;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.item-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #888;
}
.item-year {
color: #aaa;
}
.item-rating {
display: flex;
align-items: center;
gap: 4px;
color: #fbbf24;
}
.item-extras {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
color: #888;
}
@media (max-width: 768px) {
.item-title {
font-size: 13px;
}
.item-meta {
font-size: 11px;
}
}
</style>

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