Compare commits

..

2 Commits

360 changed files with 15221 additions and 23703 deletions

View File

@@ -7,106 +7,38 @@ platform:
os: linux os: linux
arch: amd64 arch: amd64
volumes:
- name: cache
host:
path: /tmp/cache
steps: steps:
- name: Load cached frontend packages - name: frontend_install
image: sinlead/drone-cache:1.0.0 image: node:13.6.0
settings: commands:
action: load - node -v
key: yarn.lock - yarn --version
mount: node_modules - name: deploy
prefix: yarn-modules-seasoned image: appleboy/drone-ssh
volumes: pull: true
- name: cache secrets:
path: /cache - ssh_key
when:
- name: Frontend install event:
image: node:24.13.1 - push
commands: branch:
- node -v - master
- yarn --version - drone-test
- yarn status: success
settings:
- name: Cache frontend packages host: 10.0.0.114
image: sinlead/drone-cache:1.0.0 username: root
settings: key:
action: save from_secret: ssh_key
key: yarn.lock command_timeout: 600s
mount: node_modules script:
prefix: yarn-modules-seasoned - /home/kevin/deploy/seasoned.sh
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: trigger:
branch:
- master
event: event:
include: include:
- pull_request
- push - 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 # config file - copy config.json.example
src/config.json src/config.json
.env
# Build directory # Build directory
dist/ dist/
lib/
# Node packages # Node packages
node_modules/ 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

@@ -5,6 +5,6 @@
"singleQuote": false, "singleQuote": false,
"bracketSpacing": true, "bracketSpacing": true,
"arrowParens": "avoid", "arrowParens": "avoid",
"vueIndentScriptAndStyle": true, "vueIndentScriptAndStyle": false,
"trailingComma": "none" "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) ![](https://github.com/dmtrbrl/tmdb-app/blob/master/docs/demo.gif)
## Demo
[TMDB Vue App](https://tmdb-vue-app.herokuapp.com/)
## Config setup ## 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 ```json
# make copy of example environment file {
cp .env.example .env "SEASONED_URL": "http://localhost:31459/api",
"ELASTIC_URL": "http://localhost:9200"
}
``` ```
*Set ELASTIC_URL to undefined or false to disable*
```bash ## Build Setup
# .env sane default values
SEASONED_API=
ELASTIC=
ELASTIC_INDEX=shows,movies
SEASONED_DOMAIN=
```
- 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. ``` bash
- 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
# install dependencies # install dependencies
yarn npm install
# build vue project using webpack # serve with hot reload at localhost:8080
yarn build npm run dev
# test or host built files using docker, might require sudo: # build for production with minification
docker build -t seasoned . npm run build
docker run -d -p 5000:5000 --name seasoned-request --env-file .env seasoned
``` ```
## Development Steps 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)
```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
```
## Documentation ## Documentation
All api functions are documented in `/docs` and [found here](docs/api.md). 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) [html version also available](http://htmlpreview.github.io/?https://github.com/KevinMidboe/seasoned/blob/release/v2/docs/api/index.html)
## License ## License
[MIT](https://github.com/dmtrbrl/tmdb-app/blob/master/LICENSE) [MIT](https://github.com/dmtrbrl/tmdb-app/blob/master/LICENSE)

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,45 @@
{ {
"name": "seasoned-request", "name": "seasoned-request",
"description": "seasoned request app", "description": "seasoned request app",
"version": "1.22.17", "version": "1.0.0",
"author": "Kevin Midboe", "author": "Kevin Midboe",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "NODE_ENV=development vite", "dev": "cross-env NODE_ENV=development webpack-dev-server --hot",
"build": "yarn vite build", "build": "cross-env NODE_ENV=production webpack --progress --hide-modules",
"lint": "eslint src; prettier -c src", "start": "node server.js",
"clean": "rm -rf dist/ yarn-*.log 2>/dev/null", "docs": "documentation build src/api.js -f html -o docs/api && documentation build src/api.js -f md -o docs/api.md"
"docs": "documentation build src/api.ts -f html -o docs/api && documentation build src/api.ts -f md -o docs/api.md"
}, },
"dependencies": { "dependencies": {
"chart.js": "3.9.1", "axios": "^0.18.1",
"vue": "3.5.28", "babel-plugin-transform-object-rest-spread": "^6.26.0",
"vue-router": "5.0.3", "chart.js": "^2.9.2",
"vuex": "4.1.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": { "devDependencies": {
"@eslint/compat": "^2.0.2", "@babel/core": "^7.4.5",
"@eslint/js": "^10.0.1", "@babel/plugin-transform-runtime": "^7.4.4",
"@types/node": "^25.3.0", "@babel/preset-env": "^7.4.5",
"@vitejs/plugin-vue": "^5.2.1", "@babel/runtime": "^7.4.5",
"eslint": "^10.0.1", "babel-loader": "^8.0.6",
"eslint-config-airbnb-extended": "^3.0.1", "cross-env": "^3.0.0",
"eslint-config-prettier": "^10.1.8", "css-loader": "^3.4.2",
"eslint-plugin-prettier": "^5.5.5", "documentation": "^11.0.0",
"prettier": "^3.8.1", "file-loader": "^0.9.0",
"sass": "1.54.3", "node-sass": "^4.5.0",
"typescript": "5.9.3", "sass-loader": "^5.0.1",
"vite": "^6.0.3" "schema-utils": "^2.4.1",
"vue-loader": "^10.0.0",
"vue-svg-inline-loader": "^1.3.1",
"vue-template-compiler": "2.6.10",
"webpack": "^2.2.0",
"webpack-dev-server": "^2.2.0"
} }
} }

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

View File

@@ -1,70 +0,0 @@
#!/usr/bin/env node
/**
* Usage: node convert-svg-to-svelte.js [inputDir] [outputDir]
* Defaults: svgs src/icons
*/
import fs from "fs";
import path from "path";
const INPUT_DIR = process.argv[2] || "svgs";
const OUTPUT_DIR = process.argv[3] || "src/icons";
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
function processSvg(svgContent) {
// Strip XML/DOCTYPE
let out = svgContent
.replace(/<\?xml[\s\S]*?\?>\s*/i, "")
.replace(/<!DOCTYPE[\s\S]*?>\s*/i, "");
// Remove ALL comments
out = out.replace(/<!--[\s\S]*?-->\s*/g, "");
// Remove <g id="icomoon-ignore"></g> with any whitespace between tags
out = out.replace(/<g\s+id=(["'])icomoon-ignore\1\s*>\s*<\/g>\s*/gi, "");
// Ensure only width="100%" height="100%" on the <svg> tag
out = out.replace(/<svg\b[^>]*>/i, match => {
let tag = match
.replace(/\s+(width|height)\s*=\s*"[^"]*"/gi, "")
.replace(/\s+(width|height)\s*=\s*'[^']*'/gi, "");
return tag.replace(/>$/, ' width="100%" height="100%">');
});
// Prepend the single license comment
out =
"<!-- generated by icomoon.io - licensed Lindua icon -->\n" +
out.replace(/^\s+/, "");
// Wrap with <template> tags
out = "<template>\n" + out + "\n</template>";
return out;
}
function convertSvgs(inputDir = INPUT_DIR, outputDir = OUTPUT_DIR) {
if (!fs.existsSync(inputDir)) {
console.warn(`Input directory not found: ${inputDir}`);
return;
}
const files = fs
.readdirSync(inputDir)
.filter(f => f.toLowerCase().endsWith(".svg"));
files.forEach(file => {
const src = path.join(inputDir, file);
const baseName = file.replace(/\.svg$/i, "");
// Convert kebab-case to PascalCase (e.g., clipboard-text -> ClipboardText)
const pascalCase = baseName
.split("-")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
const destFileName = `Icon${pascalCase}.vue`;
const dest = path.join(outputDir, destFileName);
const svgContent = fs.readFileSync(src, "utf8");
const processed = processSvg(svgContent);
fs.writeFileSync(dest, processed, "utf8");
console.log(`Converted: ${file} -> ${path.basename(dest)}`);
});
}
convertSvgs();

View File

@@ -1,63 +0,0 @@
#!/usr/bin/env node
/**
* Usage: node convert-svg-to-svelte.js [inputDir] [outputDir]
* Defaults: ./svgs ./svelte
*/
import fs from "fs";
import path from "path";
const INPUT_DIR = process.argv[2] || "../svgs";
const OUTPUT_DIR = process.argv[3] || "../src/icons";
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
function processSvg(svgContent) {
// Strip XML/DOCTYPE
let out = svgContent
.replace(/<\?xml[\s\S]*?\?>\s*/i, "")
.replace(/<!DOCTYPE[\s\S]*?>\s*/i, "");
// Remove ALL comments
out = out.replace(/<!--[\s\S]*?-->\s*/g, "");
// Remove <g id="icomoon-ignore"></g> with any whitespace between tags
out = out.replace(/<g\s+id=(["'])icomoon-ignore\1\s*>\s*<\/g>\s*/gi, "");
// Ensure only width="100%" height="100%" on the <svg> tag
out = out.replace(/<svg\b[^>]*>/i, match => {
let tag = match
.replace(/\s+(width|height)\s*=\s*"[^"]*"/gi, "")
.replace(/\s+(width|height)\s*=\s*'[^']*'/gi, "");
return tag.replace(/>$/, ' width="100%" height="100%">');
});
// Prepend the single license comment
out =
"<!-- generated by icomoon.io - licensed Lindua icon -->\n" +
out.replace(/^\s+/, "");
// Wrap with <template> tags
out = "<template>\n" + out + "\n</template>";
return out;
}
function convertSvgs(inputDir = INPUT_DIR, outputDir = OUTPUT_DIR) {
if (!fs.existsSync(inputDir)) {
console.warn(`Input directory not found: ${inputDir}`);
return;
}
const files = fs
.readdirSync(inputDir)
.filter(f => f.toLowerCase().endsWith(".svg"));
files.forEach(file => {
const src = path.join(inputDir, file);
const dest = path.join(outputDir, file.replace(/\.svg$/i, ".vue"));
const svgContent = fs.readFileSync(src, "utf8");
const processed = processSvg(svgContent);
fs.writeFileSync(dest, processed, "utf8");
console.log(`Converted: ${file} -> ${path.basename(dest)}`);
});
}
convertSvgs();

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

490
src/api.js Normal file
View File

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

View File

@@ -1,599 +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
};
const getTmdbMovieDiscoverByName = async (
name: string,
page = 1
): Promise<IList> => {
const url = new URL(`/api/v2/movie/discover/${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,
getTmdbMovieDiscoverByName,
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

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

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

View File

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

View File

@@ -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,277 +0,0 @@
<template>
<section class="discover-minimal">
<div class="discover-minimal-header">
<div class="header-content">
<h2 class="discover-title">Explore Collections</h2>
<p class="discover-description">
Curated selections organized by genre, mood, and decade
</p>
</div>
<router-link to="/discover" class="view-all-link">
<span class="desktop-only">View All Categories </span>
<span class="mobile-only">View All </span>
</router-link>
</div>
<DiscoverShowcase @select="navigateToDiscover" />
<div class="featured-collections-wrapper">
<div class="featured-collections-header">
<div class="header-decorator"></div>
<h3 class="featured-title">Featured Picks</h3>
<div class="header-decorator"></div>
</div>
<div class="featured-collections">
<ResultsSection
v-for="list in featuredLists"
:key="list.id"
:api-function="list.apiFunction"
:title="list.title"
:short-list="true"
section-type="discover"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import ResultsSection from "@/components/ResultsSection.vue";
import DiscoverShowcase from "@/components/DiscoverShowcase.vue";
import { getTmdbMovieDiscoverByName } from "../api";
const router = useRouter();
const featuredLists = [
{
id: "feel_good",
title: "Feel Good",
apiFunction: () => getTmdbMovieDiscoverByName("feel_good")
},
{
id: "2000s_classics",
title: "2000s Classics",
apiFunction: () => getTmdbMovieDiscoverByName("2000s_classics")
},
{
id: "horror_hits",
title: "Horror Hits",
apiFunction: () => getTmdbMovieDiscoverByName("horror_hits")
}
];
function navigateToDiscover(categoryId?: string) {
router.push(`/discover${categoryId ? `?category=${categoryId}` : ""}`);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.discover-minimal {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.01) 0%,
rgba(255, 255, 255, 0.03) 50%,
rgba(255, 255, 255, 0.01) 100%
);
padding: 3rem 0;
position: relative;
margin: 2rem 0;
width: 100%;
&::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 90%;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.2) 50%,
transparent 100%
);
}
&::after {
content: "";
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 90%;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 50%,
transparent 100%
);
}
@include mobile {
padding: 1rem 0 0.5rem;
margin: 0;
background: transparent;
&::before,
&::after {
display: none;
}
}
}
.discover-minimal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1.5rem 2rem;
gap: 1rem;
@include mobile {
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 1rem 0.6rem;
gap: 0.75rem;
}
.header-content {
flex: 1;
@include mobile {
min-width: 0;
}
}
.discover-title {
margin: 0 0 0.5rem;
font-size: 2rem;
font-weight: 600;
color: var(--text-color);
letter-spacing: -0.5px;
@include mobile {
font-size: 1.75rem;
margin: 0 0 0.15rem;
font-weight: 600;
}
}
.discover-description {
margin: 0;
font-size: 0.95rem;
color: $text-color-70;
font-weight: 300;
@include mobile {
display: none;
}
}
.view-all-link {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 25px;
color: $text-color-70;
font-size: 0.9rem;
font-weight: 400;
text-decoration: none;
transition: all 0.3s ease;
white-space: nowrap;
@include mobile {
padding: 0.45rem 0.85rem;
font-size: 0.75rem;
border-radius: 20px;
flex-shrink: 0;
}
&:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
color: var(--text-color);
transform: translateX(2px);
}
}
}
.featured-collections-wrapper {
padding-top: 2rem;
position: relative;
@include mobile {
margin-top: 0;
padding-top: 0.5rem;
}
}
.featured-collections-header {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0 1.5rem 1.5rem;
max-width: 1400px;
margin: 0 auto;
@include mobile {
padding: 0 1rem 0.4rem;
gap: 0.4rem;
}
.header-decorator {
flex: 1;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.3) 50%,
rgba(255, 255, 255, 0.3) 100%
);
&:last-child {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.3) 0%,
rgba(255, 255, 255, 0.3) 50%,
transparent 100%
);
}
}
.featured-title {
margin: 0;
font-size: 1.4rem;
font-weight: 500;
color: var(--text-color);
letter-spacing: 0.5px;
white-space: nowrap;
text-transform: uppercase;
font-size: 0.9rem;
color: $text-color-70;
@include mobile {
font-size: 0.8rem;
}
}
}
.featured-collections {
background: rgba(0, 0, 0, 0.15);
border-radius: 20px;
max-width: calc(100% - 4rem);
margin: 0 auto;
@include mobile {
border-radius: 12px;
padding: 0.25rem 0;
max-width: calc(100% - 2rem);
}
}
</style>

View File

@@ -1,360 +0,0 @@
<template>
<div class="category-showcase">
<div class="categories-grid">
<button
v-for="category in categories"
:key="category.id"
class="category-card"
:class="[
`category-${category.id}`,
{ active: activeCategory === category.id }
]"
@click="$emit('select', category.id)"
>
<component :is="category.icon" class="category-icon" />
<div class="category-info">
<h3 class="category-name">{{ category.label }}</h3>
<p class="category-count">
<span class="desktop-only">{{ category.count }} collections</span>
<span class="mobile-only">{{ category.count }}</span>
</p>
</div>
<div class="category-arrow"></div>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import IconPopular from "@/icons/IconPopular.vue";
import IconSpotlights from "@/icons/IconSpotlights.vue";
import IconTheater from "@/icons/IconTheater.vue";
import IconCalendar from "@/icons/IconCalendar.vue";
import IconStar from "@/icons/IconStar.vue";
interface Props {
activeCategory?: string;
}
withDefaults(defineProps<Props>(), {
activeCategory: ""
});
defineEmits<{
select: [categoryId: string];
}>();
const router = useRouter();
const categories = [
{ id: "popular", label: "Popular", icon: IconPopular, count: 5 },
{ id: "genres", label: "Genres", icon: IconSpotlights, count: 13 },
{ id: "moods", label: "Moods & Themes", icon: IconTheater, count: 7 },
{ id: "decades", label: "By Decade", icon: IconCalendar, count: 4 },
{ id: "special", label: "Special Collections", icon: IconStar, count: 11 }
];
function navigateToDiscover(categoryId?: string) {
router.push(`/discover${categoryId ? `?category=${categoryId}` : ""}`);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.category-showcase {
padding: 1.5rem;
padding-top: 0;
@include mobile {
padding: 0 1rem 0.6rem;
}
}
.categories-grid {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
max-width: 1200px;
margin: 0 auto;
justify-content: center;
@include mobile {
gap: 0.45rem;
}
}
.category-card {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 0.9rem;
padding: 1rem 1.5rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 50px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
@include mobile {
padding: 0.45rem 0.7rem;
gap: 0.4rem;
border-radius: 20px;
}
&.category-popular {
background: rgba(255, 80, 80, 0.15);
border-color: rgba(255, 80, 80, 0.3);
.category-icon {
fill: rgba(255, 120, 120, 0.9);
}
}
&.category-genres {
background: rgba(80, 140, 255, 0.15);
border-color: rgba(80, 140, 255, 0.3);
.category-icon {
fill: rgba(120, 170, 255, 0.9);
}
}
&.category-moods {
background: rgba(160, 80, 255, 0.15);
border-color: rgba(160, 80, 255, 0.3);
.category-icon {
fill: rgba(180, 120, 255, 0.9);
}
}
&.category-decades {
background: rgba(80, 200, 200, 0.15);
border-color: rgba(80, 200, 200, 0.3);
.category-icon {
fill: rgba(100, 220, 220, 0.9);
}
}
&.category-special {
background: rgba(255, 180, 80, 0.15);
border-color: rgba(255, 180, 80, 0.3);
.category-icon {
fill: rgba(255, 200, 120, 0.9);
}
}
&.active {
transform: translateY(-3px) scale(1.03);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
&::before {
opacity: 1;
}
.category-icon {
transform: rotate(5deg) scale(1.15);
}
.category-arrow {
opacity: 1;
transform: translateX(4px);
}
&.category-popular {
background: rgba(255, 80, 80, 0.3);
border-color: rgba(255, 80, 80, 0.6);
.category-icon {
fill: rgba(255, 160, 160, 1);
}
}
&.category-genres {
background: rgba(80, 140, 255, 0.3);
border-color: rgba(80, 140, 255, 0.6);
.category-icon {
fill: rgba(160, 210, 255, 1);
}
}
&.category-moods {
background: rgba(160, 80, 255, 0.3);
border-color: rgba(160, 80, 255, 0.6);
.category-icon {
fill: rgba(220, 160, 255, 1);
}
}
&.category-decades {
background: rgba(80, 200, 200, 0.3);
border-color: rgba(80, 200, 200, 0.6);
.category-icon {
fill: rgba(140, 255, 255, 1);
}
}
&.category-special {
background: rgba(255, 180, 80, 0.3);
border-color: rgba(255, 180, 80, 0.6);
.category-icon {
fill: rgba(255, 230, 160, 1);
}
}
}
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.15) 0%,
transparent 100%
);
opacity: 0;
transition: opacity 0.3s ease;
}
&:hover {
transform: translateY(-3px) scale(1.03);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
&.category-popular {
background: rgba(255, 80, 80, 0.25);
border-color: rgba(255, 80, 80, 0.5);
.category-icon {
fill: rgba(255, 140, 140, 1);
}
}
&.category-genres {
background: rgba(80, 140, 255, 0.25);
border-color: rgba(80, 140, 255, 0.5);
.category-icon {
fill: rgba(140, 190, 255, 1);
}
}
&.category-moods {
background: rgba(160, 80, 255, 0.25);
border-color: rgba(160, 80, 255, 0.5);
.category-icon {
fill: rgba(200, 140, 255, 1);
}
}
&.category-decades {
background: rgba(80, 200, 200, 0.25);
border-color: rgba(80, 200, 200, 0.5);
.category-icon {
fill: rgba(120, 240, 240, 1);
}
}
&.category-special {
background: rgba(255, 180, 80, 0.25);
border-color: rgba(255, 180, 80, 0.5);
.category-icon {
fill: rgba(255, 220, 140, 1);
}
}
&::before {
opacity: 1;
}
.category-icon {
transform: rotate(5deg) scale(1.15);
}
.category-arrow {
opacity: 1;
transform: translateX(4px);
}
}
.category-icon {
width: 24px;
height: 24px;
fill: var(--text-color);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
@include mobile {
width: 16px;
height: 16px;
}
}
.category-info {
display: flex;
align-items: center;
gap: 0.6rem;
line-height: 1;
@include mobile {
gap: 0.4rem;
}
}
.category-name {
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: white;
white-space: nowrap;
@include mobile {
font-size: 0.8rem;
}
}
.category-count {
margin: 0;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
background: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.6rem;
border-radius: 12px;
white-space: nowrap;
@include mobile {
font-size: 0.65rem;
padding: 0.15rem 0.4rem;
}
}
.category-arrow {
font-size: 1.1rem;
color: white;
opacity: 0;
transition: all 0.3s ease;
margin-left: 0.25rem;
@include mobile {
display: none;
}
}
}
</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>

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

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

View File

@@ -1,218 +1,94 @@
<template> <template>
<header ref="headerElement" :class="{ expanded, noselect: true }"> <header v-bind:style="{ 'background-image': 'url(' + imageFile + ')' }">
<img ref="imageElement" :src="bannerImage" alt="Page banner image" />
<div class="container"> <div class="container">
<h1 class="title">Request movies or tv shows</h1> <h1 class="title">Request movies or tv shows</h1>
<strong class="subtitle" <strong class="subtitle"
>Create a profile to track and view requests</strong >Create a profile to track and view requests</strong
> >
</div> </div>
<div
class="expand-icon"
@click="expand"
@keydown.enter="expand"
@mouseover="upgradeImage"
@focus="focus"
>
<IconExpand v-if="!expanded" />
<IconShrink v-else />
</div>
</header> </header>
</template> </template>
<script setup lang="ts"> <script>
import { ref } from "vue"; export default {
import IconExpand from "@/icons/IconExpand.vue"; props: {
import IconShrink from "@/icons/IconShrink.vue"; image: {
import type { Ref } from "vue"; type: String,
required: false
const ASSET_URL = "https://request.movie/assets/"; }
const images: Array<string> = [ },
"pulp-fiction.jpg", data() {
"arrival.jpg", return {
"disaster-artist.jpg", imageFile: "/pulp-fiction.jpg"
"dune.jpg", };
"mandalorian.jpg" },
]; beforeMount() {
if (this.image && this.image.length > 0) {
const bannerImage: Ref<string> = ref(); this.imageFile = this.image;
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;
} }
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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "scss/variables"; @import "./src/scss/variables";
@import "scss/media-queries"; @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;
@include tablet-min {
height: 284px;
}
&:before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%; width: 100%;
display: flex; height: 100%;
align-items: center; background-color: $background-70;
justify-content: center; transition: background-color 0.5s ease;
}
.container {
text-align: center;
position: relative; position: relative;
transition: height 0.5s ease; transition: color 0.5s ease;
overflow: hidden; }
--header-height: 25vh;
height: var(--header-height); .title {
font-weight: 500;
font-size: 22px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: $text-color;
margin: 0;
> * { @include tablet-min {
z-index: 1; font-size: 2.5rem;
}
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;
}
} }
} }
.subtitle {
display: block;
font-size: 14px;
font-weight: 300;
color: $text-color-70;
margin: 5px 0;
@include tablet-min {
font-size: 1.3rem;
}
}
}
</style> </style>

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,120 +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;
sectionType?: "list" | "discover";
}
const props = withDefaults(defineProps<Props>(), {
sectionType: "list"
});
const urlify = computed(() => {
const normalizedTitle = props.title
.toLowerCase()
.replace(/'s\b/g, "") // Remove possessive 's
.replace(/[^\w\d\s-]/g, "") // Remove special characters (keep word chars, dashes, digits, spaces)
.replace(/\s+/g, "_") // Replace spaces with underscores
.replace(/-/g, "_") // Replace dash with underscore
.replace(/_+/g, "_"); // Replace multiple underscores with single underscore
return `/${props.sectionType}/${normalizedTitle}`;
});
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>

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

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

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

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

View File

@@ -1,76 +1,68 @@
<template> <template>
<div> <ul class="results" :class="{'shortList': shortList}">
<ul <movies-list-item v-for='movie in results' :movie="movie" />
v-if="results && results.length" </ul>
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> </template>
<script setup lang="ts">
import ResultsListItem from "@/components/ResultsListItem.vue";
import type { ListResults } from "../interfaces/IList";
interface Props { <script>
results: Array<ListResults>; import MoviesListItem from '@/components/MoviesListItem'
shortList?: boolean;
loading?: boolean; export default {
} components: { MoviesListItem },
props: {
defineProps<Props>(); results: {
type: Array,
function generateResultKey(index: string | number | symbol, value: string) { required: true
return `${String(index)}-${value}`; },
shortList: {
type: Boolean,
required: false,
default: false
}
} }
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "scss/media-queries"; @import './src/scss/media-queries';
@import "scss/main";
.no-results { .results {
width: 100%; display: flex;
flex-wrap: wrap;
margin: 0;
padding: 0;
list-style: none;
&.shortList > li {
display: none;
&:nth-child(-n+4) {
display: block;
}
}
}
@include tablet-min {
.results.shortList > li:nth-child(-n+6) {
display: block; display: block;
text-align: center;
margin: 1.5rem;
font-size: 1.2rem;
} }
}
.results { @include tablet-landscape-min {
display: grid; .results.shortList > li:nth-child(-n+8) {
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); display: block;
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));
}
}
} }
}
@include desktop-min {
.results.shortList > li:nth-child(-n+10) {
display: block;
}
}
@include desktop-lg-min {
.results.shortList > li:nth-child(-n+16) {
display: block;
}
}
</style> </style>

View File

@@ -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,208 +0,0 @@
<template>
<div ref="resultSection" class="resultSection">
<page-header
v-bind="{ title, info, shortList, sectionType: props.sectionType }"
/>
<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;
sectionType?: "list" | "discover";
}
const props = withDefaults(defineProps<Props>(), {
sectionType: "list"
});
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>

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

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,519 @@
<template>
<div v-if="show" class="container">
<h2 class="torrentHeader-text">Searching for: {{ editedSearchQuery || query }}</h2>
<!-- <div class="torrentHeader">
<span class="torrentHeader-text">Searching for:&nbsp;</span>
<span id="search" :contenteditable="editSearchQuery ? true : false" class="torrentHeader-text editable">{{ editedSearchQuery || query }}</span>
<svg v-if="!editSearchQuery" class="torrentHeader-editIcon" @click="toggleEditSearchQuery">
<use xlink:href="#icon_radar"></use>
</svg>
<svg v-else class="torrentHeader-editIcon" @click="toggleEditSearchQuery">
<use xlink:href="#icon_check"></use>
</svg>
</div> -->
<div v-if="listLoaded">
<div v-if="torrents.length > 0">
<!-- <ul class="filter">
<li class="filter-item" v-for="(item, index) in release_types" @click="applyFilter(item, index)" :class="{'active': item === selectedRelaseType}">{{ item }}</li>
</ul> -->
<toggle-button :options="release_types" :selected.sync="selectedRelaseType" class="toggle"></toggle-button>
<table>
<tr class="table__header noselect">
<th @click="sortTable('name')" :class="selectedSortableClass('name')">
<span>Name</span>
<span v-if="prevCol === 'name' && direction"></span>
<span v-if="prevCol === 'name' && !direction"></span>
</th>
<th @click="sortTable('seed')" :class="selectedSortableClass('seed')">
<span>Seed</span>
<span v-if="prevCol === 'seed' && direction"></span>
<span v-if="prevCol === 'seed' && !direction"></span>
</th>
<th @click="sortTable('size')" :class="selectedSortableClass('size')">
<span>Size</span>
<span v-if="prevCol === 'size' && direction"></span>
<span v-if="prevCol === 'size' && !direction"></span>
<th>
<span>Magnet</span>
</th>
</tr>
<tr v-for="torrent in torrents" class="table__content">
<td @click="expand($event, torrent.name)">{{ torrent.name }}</td>
<td @click="expand($event, torrent.name)">{{ torrent.seed }}</td>
<td @click="expand($event, torrent.name)">{{ torrent.size }}</td>
<td @click="sendTorrent(torrent.magnet, torrent.name, $event)" class="download">
<svg class="download__icon"><use xlink:href="#iconUnmatched"></use></svg>
</td>
</tr>
</table>
<div style="
display: flex;
justify-content: center;
padding: 1rem;
">
<seasonedButton @click="resetTorrentsAndToggleEditSearchQuery">Edit search query</seasonedButton>
</div>
</div>
<div v-else style="display: flex;
padding-bottom: 2rem;
justify-content: center;
flex-direction: column;
width: 100%;
align-items: center;">
<h2>No results found</h2>
<br />
<div class="editQuery" v-if="editSearchQuery">
<seasonedInput placeholder="Torrent query" icon="_torrents" :value.sync="editedSearchQuery" @enter="fetchTorrents(editedSearchQuery)" />
<div style="height: 45px; width: 5px;"></div>
<seasonedButton @click="fetchTorrents(editedSearchQuery)">Search</seasonedButton>
</div>
<seasonedButton @click="toggleEditSearchQuery" :active="editSearchQuery ? true : false">Edit search query</seasonedButton>
</div>
</div>
<div v-else class="torrentloader"><i></i></div>
</div>
</template>
<script>
import storage from '@/storage'
import store from '@/store'
import { sortableSize } from '@/utils'
import { searchTorrents, addMagnet } from '@/api'
import SeasonedButton from '@/components/ui/SeasonedButton'
import SeasonedInput from '@/components/ui/SeasonedInput'
import ToggleButton from '@/components/ui/ToggleButton'
export default {
components: { SeasonedButton, SeasonedInput, ToggleButton },
props: {
query: {
type: String,
require: true
},
tmdb_id: {
type: Number,
require: true
},
tmdb_type: String,
admin: Boolean,
show: Boolean
},
data() {
return {
listLoaded: false,
torrents: [],
torrentResponse: undefined,
currentPage: 0,
prevCol: '',
direction: false,
release_types: ['all'],
selectedRelaseType: 'all',
editSearchQuery: false,
editedSearchQuery: ''
}
},
beforeMount() {
if (localStorage.getItem('admin')) {
this.fetchTorrents()
}
store.dispatch('torrentModule/reset')
},
watch: {
selectedRelaseType: function(newValue) {
this.applyFilter(newValue)
}
},
methods: {
selectedSortableClass(headerName) {
return headerName === this.prevCol ? 'active' : ''
},
resetTorrentsAndToggleEditSearchQuery() {
this.torrents = []
this.toggleEditSearchQuery()
},
toggleEditSearchQuery() {
this.editSearchQuery = !this.editSearchQuery;
},
expand(event, name) {
const existingExpandedElement = document.getElementsByClassName('expanded')[0]
const clickedElement = event.target.parentNode;
const scopedStyleDataVariable = Object.keys(clickedElement.dataset)[0]
if (existingExpandedElement) {
const expandedSibling = event.target.parentNode.nextSibling.className === 'expanded'
existingExpandedElement.remove()
const table = document.getElementsByTagName('table')[0]
table.style.display = 'block'
if (expandedSibling) {
return
}
}
const nameRow = document.createElement('tr')
const nameCol = document.createElement('td')
nameRow.className = 'expanded'
nameRow.dataset[scopedStyleDataVariable] = "";
nameCol.innerText = name
nameCol.dataset[scopedStyleDataVariable] = "";
nameRow.appendChild(nameCol)
clickedElement.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
}
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)
},
updateResultCountInStore() {
store.dispatch('torrentModule/setResults', this.torrents)
store.dispatch('torrentModule/setResultCount', this.torrentResponse.length)
},
fetchTorrents(query=undefined){
this.listLoaded = false;
this.editSearchQuery = false;
searchTorrents(query || this.query, 'all', this.currentPage, storage.token)
.then(data => {
this.torrentResponse = [...data.results];
this.torrents = data.results;
this.listLoaded = true;
})
.then(this.updateResultCountInStore)
.then(this.findRelaseTypes)
.catch(e => {
const error = e.toString()
this.errorMessage = error.indexOf('401') != -1 ? 'Permission denied' : 'Nothing found';
this.listLoaded = true;
});
},
}
}
</script>
<style lang="scss" scoped>
@import "./src/scss/variables";
.expanded {
display: flex;
padding: 0.25rem 1rem;
max-width: 100%;
border-left: 1px solid $text-color;
border-right: 1px solid $text-color;
border-bottom: 1px solid $text-color;
td {
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";
.toggle {
max-width: unset !important;
margin: 1rem 0;
}
.container {
background-color: $background-color;
padding: 0 1rem;
}
.torrentHeader {
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 20px;
&-text {
font-weight: 400;
text-transform: uppercase;
font-size: 14px;
color: $green;
text-align: center;
margin: 0;
@include tablet-min {
font-size: 16px
}
&.editable {
cursor: pointer;
}
}
&-editIcon {
margin-left: 10px;
margin-top: -3px;
width: 22px;
height: 22px;
&:hover {
fill: $green;
cursor: pointer;
}
}
}
table {
border-collapse: collapse;
width: 100%;
table-layout: fixed;
}
.table__content, .table__header {
display: flex;
padding: 0;
border-left: 1px solid $text-color;
border-right: 1px solid $text-color;
border-bottom: 1px solid $text-color;
th, td {
display: flex;
flex-direction: column;
flex-basis: 100%;
padding: 0.4rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
min-width: 75px;
}
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 $text-color;
}
}
.table__content:last-child {
margin-bottom: 1rem;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
.table__header {
color: $text-color;
text-transform: uppercase;
cursor: pointer;
background-color: $background-color-secondary;
border-top: 1px solid $text-color;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
th {
display: flex;
flex-direction: row;
font-weight: 400;
letter-spacing: 0.7px;
// font-size: 1.08rem;
font-size: 15px;
&::before {
content: '';
min-width: 0.2rem;
}
span:first-child {
margin-right: 0.6rem;
}
span:nth-child(2) {
margin-right: 0.1rem;
}
}
th:not(:last-child) {
border-right: 1px solid $text-color;
}
}
.editQuery {
display: flex;
width: 70%;
justify-content: center;
@include mobile-only {
width: 90%;
}
}
.download {
&__icon {
fill: $text-color-70;
height: 1.2rem;
&:hover {
fill: $text-color;
cursor: pointer;
}
}
&.active &__icon {
fill: $green;
}
}
.torrentloader {
width: 100%;
padding: 2rem 0;
i {
animation: load 1s linear infinite;
border: 2px solid $text-color;
border-radius: 50%;
display: block;
height: 30px;
left: 50%;
margin: 0 auto;
width: 30px;
&:after {
border: 5px solid $green;
border-radius: 50%;
content: '';
left: 10px;
position: absolute;
top: 16px;
}
}
}
@keyframes load {
100% { transform: rotate(360deg); }
}
</style>

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,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,105 +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 IconMailboxFull from "@/icons/IconMailboxFull.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 IconHelm from "@/icons/IconHelm.vue";
import IconDiscover from "@/icons/IconDiscover.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: IconMailboxFull
},
{
title: "Now Playing",
route: "/list/now_playing",
icon: IconNowPlaying
},
{
title: "Discover",
route: "/discover",
icon: IconDiscover
},
{
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: IconHelm
},
{
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

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

View File

@@ -1,382 +0,0 @@
<template>
<div class="modal-overlay library-modal-overlay" @click="emit('close')">
<div class="library-modal-content" @click.stop>
<div class="library-modal-header">
<div class="library-header-title">
<div class="library-icon-large">
<component :is="libraryIconComponent" />
</div>
<div>
<h3>{{ getLibraryTitle(libraryType) }}</h3>
<p class="library-subtitle">{{ details.total }} items</p>
</div>
</div>
<button class="close-btn" @click="emit('close')">
<IconClose />
</button>
</div>
<div class="library-modal-body">
<!-- Stats Overview -->
<div class="library-stats-overview">
<div class="overview-stat">
<span class="overview-label">Total Items</span>
<span class="overview-value">{{
formatNumber(details.total)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'tv shows'">
<span class="overview-label">Seasons</span>
<span class="overview-value">{{
formatNumber(details?.childCount)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'tv shows'">
<span class="overview-label">Episodes</span>
<span class="overview-value">{{
formatNumber(details?.leafCount)
}}</span>
</div>
<div class="overview-stat" v-if="libraryType === 'music'">
<span class="overview-label">Tracks</span>
<span class="overview-value">{{ details?.totalTracks }}</span>
</div>
<div class="overview-stat">
<span class="overview-label">Duration</span>
<span class="overview-value">{{
convertSecondsToHumanReadable(details?.duration / 1000)
}}</span>
</div>
</div>
<!-- Recently Added -->
<div class="library-section">
<h4 class="section-title">Recently Added</h4>
<div class="recent-items-grid">
<PlexLibraryItem
v-for="(item, index) in recentlyAdded"
:key="index"
:item="item"
:show-extras="
libraryType === 'music' || libraryType === 'tv shows'
"
/>
</div>
</div>
<!-- Top Genres -->
<div class="library-section">
<h4 class="section-title">Top Genres</h4>
<div class="genre-list">
<div
v-for="(genre, index) in details.genres"
:key="index"
class="genre-item"
>
<span class="genre-name">{{ genre.name }}</span>
<div class="genre-bar-container">
<div
class="genre-bar"
:style="{
width: (genre.count / details.total) * 100 + '%'
}"
></div>
</div>
<span class="genre-count">{{ genre.count }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from "vue";
import IconClose from "@/icons/IconClose.vue";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
import PlexLibraryItem from "@/components/plex/PlexLibraryItem.vue";
import { getLibraryTitle } from "@/utils/plexHelpers";
import { plexRecentlyAddedInLibrary } from "@/api";
import { processLibraryItem } from "@/utils/plexHelpers";
import { formatNumber, convertSecondsToHumanReadable } from "@/utils";
import { usePlexAuth } from "@/composables/usePlexAuth";
const { getPlexAuthCookie } = usePlexAuth();
const authToken = getPlexAuthCookie();
interface LibraryDetails {
id: number;
title: string;
total: number;
childCount?: number;
leafCount?: number;
duration: number;
genres: Array<{
name: string;
count: number;
}>;
}
interface Props {
libraryType: string;
details: LibraryDetails;
serverUrl: string;
serverMachineId: string;
}
const props = defineProps<Props>();
let recentlyAdded = ref([]);
const emit = defineEmits<{
(e: "close"): void;
}>();
const libraryIconComponent = computed(() => {
if (props.libraryType === "movies") return IconMovie;
if (props.libraryType === "tv shows") return IconShow;
if (props.libraryType === "music") return IconMusic;
return IconMovie;
});
function fetchRecentlyAdded() {
plexRecentlyAddedInLibrary(props.details.id).then(added => {
recentlyAdded.value = added?.MediaContainer?.Metadata.map(el =>
processLibraryItem(
el,
props.libraryType,
authToken,
props.serverUrl,
props.serverMachineId
)
);
});
}
function checkEventForEscapeKey(event: KeyboardEvent) {
if (event.key !== "Escape") return;
emit("close");
}
window.addEventListener("keyup", checkEventForEscapeKey);
onMounted(() => {
fetchRecentlyAdded();
});
onBeforeUnmount(() => {
window.removeEventListener("keyup", checkEventForEscapeKey);
});
</script>
<style lang="scss" scoped>
@import "scss/media-queries.scss";
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
@include mobile {
padding: 0;
}
}
.library-modal-content {
background: #1a1a1a;
border-radius: 12px;
width: 100%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
@include mobile {
max-height: 100vh;
border-radius: unset;
}
}
.library-modal-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
border-bottom: 1px solid #333;
}
.library-header-title {
display: flex;
align-items: center;
gap: 16px;
}
.library-icon-large {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
fill: var(--highlight-color);
}
}
.library-modal-header h3 {
margin: 0;
font-size: 24px;
color: #fff;
}
.library-subtitle {
margin: 4px 0 0;
font-size: 14px;
color: #888;
}
.close-btn {
--size: 2.4rem;
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.5rem;
height: var(--size);
width: var(--size);
border-radius: 6px;
fill: white;
transition: all 0.2s;
@include mobile {
margin: auto 0;
}
}
.close-btn:hover {
background: #333;
color: #fff;
}
.library-modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.library-stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.overview-stat {
background: #252525;
padding: 16px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.overview-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.overview-value {
font-size: 24px;
font-weight: 600;
color: #fff;
}
.library-section {
margin-bottom: 32px;
}
.section-title {
margin: 0 0 16px;
font-size: 18px;
color: #fff;
font-weight: 600;
}
.recent-items-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 20px;
}
.genre-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.genre-item {
display: grid;
grid-template-columns: 120px 1fr 60px;
align-items: center;
gap: 12px;
}
.genre-name {
font-size: 14px;
color: #fff;
}
.genre-bar-container {
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
}
.genre-bar {
height: 100%;
background: linear-gradient(90deg, #e5a00d 0%, #ffbf3f 100%);
border-radius: 4px;
transition: width 0.3s ease;
}
.genre-count {
font-size: 14px;
color: #888;
text-align: right;
}
@media (max-width: 768px) {
.library-stats-overview {
grid-template-columns: repeat(2, 1fr);
}
.recent-items-grid {
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 16px;
}
.genre-item {
grid-template-columns: 100px 1fr 50px;
}
}
</style>

View File

@@ -1,219 +0,0 @@
<template>
<div class="library-stats">
<div
v-for="stat in displayStats"
:key="stat.key"
class="stat-card"
:class="{
disabled: stat.value === undefined || stat.value === 0 || loading,
unclickable: !!!stat.clickable
}"
@click="
stat.clickable &&
stat.value?.total > 0 &&
!loading &&
handleClick(stat.key)
"
>
<div class="stat-icon">
<component :is="stat.icon" />
</div>
<div class="stat-content">
<div class="stat-value" v-if="!loading">
{{ formatNumber(stat.value?.total) }}
</div>
<div class="stat-value loading-dots" v-else>...</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { formatNumber } from "@/utils";
import IconMovie from "@/icons/IconMovie.vue";
import IconShow from "@/icons/IconShow.vue";
import IconMusic from "@/icons/IconMusic.vue";
import IconClock from "@/icons/IconClock.vue";
interface LibraryStat {
id: number;
title: string;
total: number;
childCount?: number;
leafCount?: number;
}
interface Props {
movies: LibraryStat;
shows: LibraryStat;
music: LibraryStat;
watchtime: number;
loading?: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
openLibrary: [type: string];
}>();
const displayStats = computed(() => [
{
key: "movies",
icon: IconMovie,
value: props.movies,
label: "Movies",
clickable: true
},
{
key: "tv shows",
icon: IconShow,
value: props.shows,
label: "TV Shows",
clickable: true
},
{
key: "music",
icon: IconMusic,
value: props.music,
label: "Albums",
clickable: true
},
{
key: "watchtime",
icon: IconClock,
value: props.watchtime,
label: "Hours Watched",
clickable: false
}
]);
function handleClick(type: string) {
emit("openLibrary", type);
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.library-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin-bottom: 0.85rem;
@include tablet-only {
grid-template-columns: repeat(3, 1fr);
}
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 0.65rem;
}
}
.stat-card {
background-color: var(--background-ui);
border-radius: 0.5rem;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: all 0.2s ease;
border: 1px solid transparent;
@include mobile-only {
padding: 0.85rem 0.75rem;
}
&:hover:not(.disabled, .unclickable) {
background-color: var(--background-40);
border-color: var(--highlight-color);
cursor: pointer;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.disabled {
opacity: 0.6;
&:hover {
transform: none;
border-color: transparent;
}
}
}
.stat-icon {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
@include mobile-only {
width: 2rem;
height: 2rem;
}
svg {
width: 100%;
height: 100%;
fill: var(--highlight-color);
transition: fill 0.2s ease;
}
}
.stat-card:hover:not(.disabled) .stat-icon svg {
fill: var(--color-green-90);
}
.stat-content {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-color);
line-height: 1;
margin-bottom: 0.25rem;
@include mobile-only {
font-size: 1.3rem;
}
}
.stat-label {
font-size: 0.75rem;
color: var(--text-color-60);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
@include mobile-only {
font-size: 0.7rem;
}
}
.loading-dots {
animation: loadingDots 1.5s infinite;
}
@keyframes loadingDots {
0%,
20% {
opacity: 0.3;
}
50% {
opacity: 1;
}
100% {
opacity: 0.3;
}
}
</style>

View File

@@ -1,309 +0,0 @@
<template>
<div v-if="username" class="plex-profile-card">
<div class="profile-header">
<img
v-if="userData?.thumb"
:src="userData.thumb"
alt="Profile"
class="profile-avatar"
/>
<div v-else class="profile-avatar-placeholder">
{{ username.charAt(0).toUpperCase() }}
</div>
<div class="profile-info">
<div class="username-row">
<h3 class="profile-username">{{ username }}</h3>
<svg
class="connected-checkmark"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
<div v-if="userData?.email" class="profile-email">
{{ userData.email }}
</div>
<div class="profile-badges">
<div
v-if="userData?.subscription?.active"
class="profile-badge plex-pass"
>
<svg
width="14"
height="14"
viewBox="0 0 256 256"
fill="currentColor"
>
<path
d="M128 0C57.3 0 0 57.3 0 128s57.3 128 128 128 128-57.3 128-128S198.7 0 128 0zm0 230.4C66.9 230.4 25.6 189.1 25.6 128S66.9 25.6 128 25.6 230.4 66.9 230.4 128 189.1 230.4 128 230.4z"
/>
</svg>
Plex Pass
</div>
<div v-if="userData?.joined_at" class="profile-badge member-since">
{{ formatMemberSince(userData.joined_at) }}
</div>
<div
v-if="userData?.two_factor_enabled"
class="profile-badge two-factor"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
2FA
</div>
<div
v-if="userData?.experimental_features"
class="profile-badge experimental"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
Labs
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
username: string;
userData: any;
}
defineProps<Props>();
function formatMemberSince(dateString: string) {
try {
const date = new Date(dateString);
const now = new Date();
const years = now.getFullYear() - date.getFullYear();
if (years === 0) return "New Member";
if (years === 1) return "1 Year";
return `${years} Years`;
} catch {
return "Member";
}
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.plex-profile-card {
background-color: var(--background-ui);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.85rem;
border: 1px solid var(--background-40);
@include mobile-only {
padding: 0.85rem;
}
}
.profile-header {
display: flex;
gap: 0.85rem;
align-items: center;
}
.profile-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--highlight-color);
flex-shrink: 0;
@include mobile-only {
width: 50px;
height: 50px;
}
}
.profile-avatar-placeholder {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--highlight-color),
var(--background-40)
);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color);
flex-shrink: 0;
@include mobile-only {
width: 50px;
height: 50px;
font-size: 1.3rem;
}
}
.profile-info {
flex: 1;
min-width: 0;
}
.username-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.profile-username {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
@include mobile-only {
font-size: 1.1rem;
}
}
.connected-checkmark {
color: var(--color-success-highlight);
flex-shrink: 0;
animation: checkmarkPop 0.3s ease-out;
@include mobile-only {
width: 18px;
height: 18px;
}
}
@keyframes checkmarkPop {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.profile-email {
font-size: 0.85rem;
color: var(--text-color-60);
margin-bottom: 0.4rem;
word-break: break-all;
@include mobile-only {
font-size: 0.8rem;
}
}
.profile-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.profile-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.65rem;
border-radius: 1rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
@include mobile-only {
font-size: 0.65rem;
padding: 0.2rem 0.55rem;
}
&.plex-pass {
background-color: #cc7b19;
color: $white;
text-transform: uppercase;
svg {
width: 12px;
height: 12px;
@include mobile-only {
width: 11px;
height: 11px;
}
}
}
&.member-since {
background-color: var(--background-40);
color: var(--text-color-70);
}
&.two-factor {
background-color: var(--color-success);
color: $white;
svg {
width: 11px;
height: 11px;
@include mobile-only {
width: 10px;
height: 10px;
}
}
}
&.experimental {
background-color: #8b5cf6;
color: $white;
svg {
width: 11px;
height: 11px;
@include mobile-only {
width: 10px;
height: 10px;
}
}
}
}
</style>

View File

@@ -1,126 +0,0 @@
<template>
<div class="plex-server-info">
<div class="plex-details">
<div class="detail-row">
<span class="detail-label">
<IconServer class="label-icon" style="fill: var(--text-color)" />
Plex server name
</span>
<span class="detail-value">{{ serverName || "Unknown" }}</span>
</div>
<div class="detail-row">
<span class="detail-label">
<IconSync class="label-icon" style="stroke: var(--text-color)" />
Last Sync
</span>
<span class="detail-value">{{ lastSync || "Never" }}</span>
</div>
</div>
<div class="plex-actions">
<seasoned-button @click="$emit('sync')" :disabled="syncing">
<IconSync v-if="!syncing" class="button-icon" />
{{ syncing ? "Syncing..." : "Sync Library" }}
</seasoned-button>
<seasoned-button @click="$emit('unlink')">
Unlink Account
</seasoned-button>
</div>
</div>
</template>
<script setup lang="ts">
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import IconServer from "@/icons/IconServer.vue";
import IconSync from "@/icons/IconSync.vue";
interface Props {
serverName: string;
lastSync: string;
syncing?: boolean;
}
defineProps<Props>();
defineEmits<{
sync: [];
unlink: [];
}>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.plex-details {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.85rem;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.55rem 0.65rem;
background-color: var(--background-ui);
border-radius: 0.25rem;
@include mobile-only {
padding: 0.5rem 0.6rem;
}
}
.detail-label {
font-size: 0.85rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
@include mobile-only {
font-size: 0.8rem;
}
svg {
flex-shrink: 0;
}
.label-icon {
width: 16px;
height: 16px;
}
}
.detail-value {
font-size: 0.95rem;
@include mobile-only {
font-size: 0.9rem;
}
}
.plex-actions {
display: flex;
gap: 0.65rem;
@include mobile-only {
flex-direction: column;
gap: 0.6rem;
}
button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
svg {
flex-shrink: 0;
}
.button-icon {
width: 16px;
height: 16px;
}
}
}
</style>

View File

@@ -1,152 +0,0 @@
<template>
<div class="modal-overlay" @click="emit('cancel')">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>Unlink Plex Account</h3>
<button class="close-btn" @click="emit('cancel')">
<IconClose />
</button>
</div>
<div class="modal-body">
<p>
Are you sure you want to unlink your Plex account? You will lose
access to:
</p>
<ul>
<li>Watch history tracking</li>
<li>Recently added content notifications</li>
<li>Real-time download progress</li>
</ul>
</div>
<div class="modal-footer">
<button class="cancel-btn" @click="emit('cancel')">Cancel</button>
<button class="confirm-btn" @click="emit('confirm')">
Unlink Account
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconClose from "@/icons/IconClose.vue";
const emit = defineEmits<{
(e: "confirm"): void;
(e: "cancel"): void;
}>();
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
background: #1a1a1a;
border-radius: 12px;
width: 100%;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
border-bottom: 1px solid #333;
}
.modal-header h3 {
margin: 0;
font-size: 20px;
color: #fff;
}
.close-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 8px;
border-radius: 6px;
transition: all 0.2s;
}
.close-btn:hover {
background: #333;
color: #fff;
}
.modal-body {
padding: 24px;
}
.modal-body p {
margin: 0 0 16px;
color: #ccc;
font-size: 14px;
line-height: 1.6;
}
.modal-body ul {
margin: 0;
padding-left: 20px;
color: #aaa;
font-size: 14px;
line-height: 1.8;
}
.modal-body li {
margin-bottom: 8px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #333;
}
.cancel-btn,
.confirm-btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.cancel-btn {
background: #333;
color: #fff;
}
.cancel-btn:hover {
background: #444;
}
.confirm-btn {
background: #dc2626;
color: #fff;
}
.confirm-btn:hover {
background: #b91c1c;
}
</style>

View File

@@ -1,96 +0,0 @@
<template>
<li
class="sidebar-list-element"
:class="{ active, disabled }"
:tabindex="disabled ? -1 : 0"
role="button"
:aria-disabled="disabled"
@click="emit('click')"
@keydown.enter.prevent="emit('click')"
@keydown.space.prevent="emit('click')"
>
<slot></slot>
</li>
</template>
<script setup lang="ts">
interface Props {
active?: boolean;
disabled?: boolean;
}
interface Emit {
(e: "click");
}
const emit = defineEmits<Emit>();
defineProps<Props>();
</script>
<style lang="scss">
@import "scss/media-queries";
li.sidebar-list-element {
display: flex;
align-items: center;
text-decoration: none;
text-transform: uppercase;
color: var(--text-color-50);
font-size: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--text-color-5);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
&:first-of-type {
padding-top: 0;
}
div > svg,
svg {
width: 26px;
height: 26px;
margin-right: 1rem;
transition: all 0.3s ease;
fill: var(--text-color-70);
}
&:hover,
&:focus,
&.active {
color: var(--text-color);
outline: none;
div > svg,
svg {
fill: var(--text-color);
transform: scale(1.1, 1.1);
}
}
&:focus-visible {
outline: 2px solid var(--highlight-color);
outline-offset: 2px;
border-radius: 4px;
}
&.active > div > svg,
&.active > svg {
fill: var(--highlight-color);
}
&.disabled {
cursor: default;
}
.pending {
color: #f8bd2d;
}
.meta {
margin-left: auto;
text-align: right;
}
}
</style>

View File

@@ -1,127 +0,0 @@
<template>
<div
ref="descriptionElement"
class="movie-description noselect"
@click="overflow ? (truncated = !truncated) : null"
@keydown.enter="overflow ? (truncated = !truncated) : null"
>
<span :class="{ truncated }">{{ description }}</span>
<button v-if="description && overflow" class="truncate-toggle">
<IconArrowDown :class="{ rotate: !truncated }" />
</button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import type { Ref } from "vue";
import IconArrowDown from "../../icons/IconArrowDown.vue";
interface Props {
description: string;
}
const props = defineProps<Props>();
const truncated: Ref<boolean> = ref(true);
const overflow: Ref<boolean> = ref(false);
const descriptionElement: Ref<HTMLElement> = ref(null);
function removeElements(elems: NodeListOf<Element>) {
elems.forEach(el => el.remove());
}
// The description element overflows text after 4 rows with css
// line-clamp this takes the same text and adds to a temporary
// element without css overflow. If the temp element is
// higher then description element, we display expand button
function checkDescriptionOverflowing() {
const element = descriptionElement?.value;
if (!element) return;
const { height, width } = element.getBoundingClientRect();
const { fontSize, lineHeight } = getComputedStyle(element);
const descriptionComparisonElement = document.createElement("div");
descriptionComparisonElement.setAttribute(
"style",
`max-width: ${Math.ceil(
width + 10
)}px; display: block; font-size: ${fontSize}; line-height: ${lineHeight};`
);
// Don't know why need to add 10px to width, but works out perfectly
descriptionComparisonElement.classList.add("dummy-non-overflow");
descriptionComparisonElement.innerText = props.description;
document.body.appendChild(descriptionComparisonElement);
const elemWithoutOverflowHeight =
descriptionComparisonElement.getBoundingClientRect().height;
overflow.value = elemWithoutOverflowHeight > height;
removeElements(document.querySelectorAll(".dummy-non-overflow"));
}
onMounted(checkDescriptionOverflowing);
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.movie-description {
font-weight: 300;
font-size: 13px;
line-height: 1.8;
margin-bottom: 20px;
transition: all 1s ease;
@include tablet-min {
margin-bottom: 30px;
font-size: 14px;
}
}
span.truncated {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.truncate-toggle {
border: none;
background: none;
width: 100%;
height: 30px;
display: flex;
align-items: center;
text-align: center;
color: var(--text-color);
margin-top: 1rem;
cursor: pointer;
svg {
transition: 0.4s ease all;
height: 22px;
width: 22px;
fill: var(--text-color);
&.rotate {
transform: rotateX(180deg);
}
}
&::before,
&::after {
content: "";
flex: 1;
border-bottom: 1px solid var(--text-color-50);
}
&::before {
margin-right: 1rem;
}
&::after {
margin-left: 1rem;
}
}
</style>

View File

@@ -1,52 +0,0 @@
<template>
<div class="movie-detail">
<h2 class="title">{{ title }}</h2>
<span v-if="detail" class="info">{{ detail }}</span>
<slot></slot>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string;
detail?: string | number;
}
defineProps<Props>();
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.movie-detail {
margin-bottom: 20px;
&:last-of-type {
margin-bottom: 0px;
}
@include tablet-min {
margin-bottom: 30px;
}
h2.title {
margin: 0;
font-weight: 400;
text-transform: uppercase;
font-size: 1.2rem;
color: var(--highlight-color);
@include mobile {
font-size: 1.1rem;
}
}
span.info {
font-weight: 300;
font-size: 1rem;
letter-spacing: 0.8px;
margin-top: 5px;
}
}
</style>

View File

@@ -1,630 +0,0 @@
<template>
<section class="movie">
<!-- HEADER w/ POSTER -->
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events -->
<header
ref="backdropElement"
:class="compact ? 'compact' : ''"
@click="compact = !compact"
>
<figure class="movie__poster">
<img
ref="poster-image"
class="movie-item__img is-loaded"
alt="Movie poster"
:src="poster"
/>
</figure>
<div v-if="media" class="movie__title">
<h1>{{ media.title }}</h1>
<i>{{ media.tagline }}</i>
</div>
<loading-placeholder v-else :count="2" />
</header>
<!-- Siderbar and movie info -->
<div class="movie__main">
<div class="movie__wrap movie__wrap--main">
<!-- SIDEBAR ACTIONS -->
<div v-if="media" class="movie__actions">
<action-button :active="media?.exists_in_plex" :disabled="true">
<IconThumbsUp v-if="media?.exists_in_plex" />
<IconThumbsDown v-else />
{{
!media?.exists_in_plex ? "Not yet available" : "Already available"
}}
</action-button>
<action-button :active="requested" @click="sendRequest">
<transition name="fade" mode="out-in">
<div v-if="!requested" key="request"><IconRequest /></div>
<div v-else key="requested"><IconRequested /></div>
</transition>
{{ !requested ? `Request ${type}?` : "Already requested" }}
</action-button>
<action-button v-if="admin && requested" :active="false">
<IconTombstone />
Remove request
</action-button>
<action-button
v-if="plexUserId && media?.exists_in_plex"
@click="openInPlex"
>
<IconPlay />
Open and watch in plex now!
</action-button>
<action-button
v-if="cast?.length"
:active="showCast"
@click="() => (showCast = !showCast)"
>
<IconProfile class="icon" />
{{ showCast ? "Hide cast" : "Show cast" }}
</action-button>
<action-button
v-if="admin === true"
:active="showTorrents"
@click="
showTorrents = !showTorrents;
helmKey++;
"
>
<IconHelm
:key="helmKey"
:class="showTorrents ? 'helm-spin-forward' : 'helm-spin-reverse'"
/>
Search for torrents
<span v-if="numberOfTorrentResults" class="meta">{{
numberOfTorrentResults
}}</span>
</action-button>
<action-button @click="openTmdb">
<IconInfo />
See more info
</action-button>
</div>
<!-- Loading placeholder -->
<div v-else class="movie__actions text-input__loading">
<div
v-for="index in admin ? Array(4) : Array(3)"
:key="index"
class="movie__actions-link"
>
<div
class="movie__actions-text text-input__loading--line"
style="margin: 9px; margin-left: -3px"
></div>
</div>
</div>
<!-- MOVIE INFO -->
<div class="movie__info">
<!-- Loading placeholder -->
<div v-if="loading">
<loading-placeholder :count="5" />
</div>
<Description
v-if="!loading && media && media.overview"
:description="media.overview"
/>
<div v-if="media" class="movie__details">
<Detail
v-if="media.year"
title="Release date"
:detail="media.year"
/>
<Detail
v-if="media.type === MediaTypes.Movie && media.rating"
title="Rating"
:detail="media.rating"
/>
<Detail
v-if="media.type == MediaTypes.Show"
title="Seasons"
:detail="media.seasons"
/>
<Detail
v-if="media.genres && media.genres.length"
title="Genres"
:detail="media.genres.join(', ')"
/>
<Detail
v-if="
media.production_status &&
media.production_status !== 'Released'
"
title="Production status"
:detail="media.production_status"
/>
<Detail
v-if="media.runtime"
title="Runtime"
:detail="humanMinutes(media.runtime)"
/>
</div>
</div>
<!-- TODO: change this classname, this is general -->
<div v-if="showCast && cast?.length" class="movie__admin">
<Detail title="cast">
<CastList :cast="cast" />
</Detail>
</div>
</div>
<!-- TORRENT LIST -->
<TorrentList
v-if="media && admin && showTorrents"
class="torrents"
:query="media?.title"
:tmdb-id="id"
></TorrentList>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
import type { Ref } from "vue";
// import img from "@/directives/v-image";
import IconProfile from "../../icons/IconProfile.vue";
import IconThumbsUp from "../../icons/IconThumbsUp.vue";
import IconThumbsDown from "../../icons/IconThumbsDown.vue";
import IconInfo from "../../icons/IconInfo.vue";
import IconRequest from "../../icons/IconRequest.vue";
import IconRequested from "../../icons/IconRequested.vue";
import IconHelm from "../../icons/IconHelm.vue";
import IconPlay from "../../icons/IconPlay.vue";
import IconTombstone from "../../icons/IconTombstone.vue";
import TorrentList from "../torrent/TruncatedTorrentResults.vue";
import CastList from "../CastList.vue";
import Detail from "./Detail.vue";
import ActionButton from "./ActionButton.vue";
import Description from "./Description.vue";
import LoadingPlaceholder from "../ui/LoadingPlaceholder.vue";
import type { IColors } from "../../interfaces/IColors.ts";
import type {
IMovie,
IShow,
IMediaCredits,
ICast
} from "../../interfaces/IList";
import type {
IRequestStatusResponse,
IRequestSubmitResponse
} from "../../interfaces/IRequestResponse";
import { MediaTypes } from "../../interfaces/IList";
import { humanMinutes } from "../../utils";
import {
getMovie,
getShow,
getMovieCredits,
getShowCredits,
request,
getRequestStatus
// watchLink
} from "../../api";
interface Props {
id: number;
type: MediaTypes.Movie | MediaTypes.Show;
}
const props = defineProps<Props>();
const ASSET_URL = "https://image.tmdb.org/t/p/";
const COLORS_API = import.meta.env.VITE_SEASONED_COLORS_API || "";
const ASSET_SIZES = ["w500", "w780", "original"];
const media: Ref<IMovie | IShow> = ref();
const requested: Ref<boolean> = ref();
const showTorrents: Ref<boolean> = ref();
const showCast: Ref<boolean> = ref();
const cast: Ref<ICast[]> = ref([]);
const compact: Ref<boolean> = ref();
const loading: Ref<boolean> = ref();
const backdropElement: Ref<HTMLElement> = ref();
const helmKey: Ref<number> = ref(0);
const store = useStore();
const admin = computed(() => store.getters["user/admin"]);
const plexUserId = computed(() => store.getters["user/plexUserId"]);
const poster = computed(() => {
if (!media.value) return "/assets/placeholder.png";
if (!media.value?.poster) return "/assets/no-image.svg";
// compute & update highlight colors from poster image
colorsFromPoster(media.value.poster); // eslint-disable-line
return `${ASSET_URL}${ASSET_SIZES[0]}${media.value.poster}`;
});
const numberOfTorrentResults = computed(() => {
const count = store.getters["torrentModule/resultCount"];
return count ? `${count} results` : null;
});
function setCast(_cast: ICast[]) {
cast.value = _cast;
}
function setRequested(
requestResponse: IRequestStatusResponse | IRequestSubmitResponse
) {
if (requestResponse?.success) {
requested.value = requestResponse?.success;
return;
}
requested.value = false;
}
function setBackdrop(): void {
if (
!media.value?.backdrop ||
!backdropElement.value?.style ||
backdropElement.value?.style?.backgroundImage !== ""
)
return;
const backdropURL = `${ASSET_URL}${ASSET_SIZES[1]}${media.value.backdrop}`;
backdropElement.value.style.backgroundImage = `url(${backdropURL})`;
}
function getCredits(
type: MediaTypes.Movie | MediaTypes.Show
): Promise<IMediaCredits> {
if (type === MediaTypes.Movie) {
return getMovieCredits(props.id);
}
if (type === MediaTypes.Show) {
return getShowCredits(props.id);
}
return Promise.reject();
}
function setAndReturnMedia(_media: IMovie | IShow) {
media.value = _media;
return _media;
}
function fetchMedia() {
if (!props.id || !props.type) {
console.error("Unable to fetch media, requires id & type"); // eslint-disable-line no-console
return;
}
let apiFunction: typeof getMovie;
let parameters: {
checkExistance: boolean;
credits: boolean;
releaseDates?: boolean;
};
if (props.type === MediaTypes.Movie) {
apiFunction = getMovie;
parameters = { checkExistance: true, credits: false };
} else if (props.type === MediaTypes.Show) {
apiFunction = getShow;
parameters = { checkExistance: true, credits: false };
}
apiFunction(props.id, { ...parameters })
.then(setAndReturnMedia)
.then(() => getCredits(props.type))
.then(credits => setCast(credits?.cast || []))
.then(() => getRequestStatus(props.id, props.type))
.then(requestResponse => setRequested(requestResponse))
.then(setBackdrop);
}
function sendRequest() {
request(props.id, props.type).then(requestResponse =>
setRequested(requestResponse)
);
}
function openInPlex(): boolean {
// watchLink()
return false;
}
function openTmdb() {
const tmdbType = props.type === MediaTypes.Show ? "tv" : props.type;
const tmdbURL = `https://www.themoviedb.org/${tmdbType}/${props.id}`;
window.location.href = tmdbURL;
}
function colorMain(colors: IColors) {
const parent = document.getElementsByClassName(
"movie-popup"
)[0] as HTMLElement;
parent.style.setProperty("--highlight-color", colors.s ?? colors.p);
parent.style.setProperty("--highlight-bg", colors.bg);
parent.style.setProperty("--highlight-secondary", colors.p);
parent.style.setProperty("--text-color", "#ffffff");
parent.style.setProperty("--text-color-90", "rgba(255, 255, 255, 0.9)");
parent.style.setProperty("--text-color-70", "rgba(255, 255, 255, 0.7)");
parent.style.setProperty("--text-color-50", "rgba(255, 255, 255, 0.5)");
parent.style.setProperty("--text-color-10", "rgba(255, 255, 255, 0.1)");
parent.style.setProperty("--text-color-5", "rgba(255, 255, 255, 0.05)");
}
async function colorsFromPoster(posterPath: string) {
const url = new URL("/colors", COLORS_API);
url.searchParams.append("id", posterPath.replace("/", ""));
url.searchParams.append("size", "w342");
fetch(url.href)
.then(resp => {
if (resp.ok) return resp.json();
throw new Error(`invalid status: '${resp.status}' from server.`);
})
.then(colorMain)
.catch(error => console.log("unable to get colors, error:", error)); // eslint-disable-line no-console
}
// On created functions
fetchMedia();
store.dispatch("torrentModule/setResultCount", null);
// End on create functions
onMounted(setBackdrop);
</script>
<style lang="scss" scoped>
@import "scss/loading-placeholder";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
header {
$duration: 0.2s;
transform: scaleY(1);
transition: height $duration ease;
transform-origin: top;
position: relative;
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;
background-color: $background-color;
display: grid;
grid-template-columns: 1fr 1fr;
height: 350px;
@include mobile {
grid-template-columns: 1fr;
height: 250px;
place-items: center;
}
* {
z-index: 2;
}
&::before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: 100%;
height: 100%;
background: $background-dark-85;
}
@include mobile {
&.compact {
height: 100px;
}
}
}
.movie__poster {
display: none;
border-radius: 1.6rem;
@include desktop {
background: var(--background-color);
height: auto;
display: block;
width: calc(100% - 80px);
margin: 40px;
> img {
width: 100%;
border-radius: calc(1.6rem - 1px);
}
}
}
.movie {
&__wrap {
&--header {
align-items: center;
height: 100%;
}
&--main {
display: flex;
flex-wrap: wrap;
flex-direction: column;
@include tablet-min {
flex-direction: row;
}
background-color: var(--highlight-bg, var(--background-color));
color: var(--text-color);
}
}
&__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;
text-align: center;
width: 100%;
height: fit-content;
@include tablet-min {
text-align: left;
padding: 140px 30px 0 40px;
}
h1 {
color: var(--highlight-color);
font-weight: 500;
line-height: 1.2;
font-size: 2.2rem;
font-weight: 600;
letter-spacing: 1px;
margin-bottom: 0;
@include tablet-min {
font-size: 30px;
font-size: 2.2rem;
}
}
i {
display: block;
color: rgba(255, 255, 255, 0.8);
color: var(--highlight-secondary);
margin-top: 1rem;
}
}
&__actions {
text-align: center;
width: 100%;
order: 2;
padding: 20px;
border-top: 1px solid var(--text-color-50);
@include tablet-min {
order: 1;
width: 45%;
padding: 185px 0 40px 40px;
border-top: 0;
}
}
&__info {
width: 100%;
padding: 20px;
order: 1;
@include tablet-min {
order: 2;
padding: 40px;
width: 55%;
margin-left: 45%;
}
}
&__info {
margin-left: 0;
}
&__details {
display: flex;
flex-wrap: wrap;
> * {
margin-right: 30px;
@include mobile {
margin-right: 20px;
}
}
}
&__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: $green;
padding-bottom: 20px;
@include tablet-min {
font-size: 16px;
}
}
}
.torrents {
background-color: var(--highlight-bg, var(--background-color));
padding: 0 1rem;
@include mobile {
padding: 0 0.5rem;
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
.helm-spin-forward {
animation: helm-spin-forward 0.6s ease-in-out;
}
.helm-spin-reverse {
animation: helm-spin-reverse 0.6s ease-in-out;
}
@keyframes helm-spin-forward {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(270deg);
}
}
@keyframes helm-spin-reverse {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(-270deg);
}
}
</style>

View File

@@ -1,278 +0,0 @@
<template>
<section class="person">
<header ref="header">
<div class="info">
<h1 v-if="person">
{{ person.name }}
</h1>
<div v-else>
<loading-placeholder :count="1" />
<loading-placeholder :count="1" line-class="short" :top="3.5" />
</div>
<span v-if="person && person['known_for_department']" class="known-for">
{{
person.known_for_department === "Acting"
? "Actor"
: person.known_for_department
}}
</span>
</div>
<figure class="person__poster">
<img
ref="poster-image"
class="person-item__img is-loaded"
alt="Image of person"
:src="poster"
/>
</figure>
</header>
<div v-if="loading">
<loading-placeholder :count="6" />
<loading-placeholder line-class="short" :top="3" />
<loading-placeholder :count="6" line-class="fullwidth" />
<loading-placeholder line-class="short" :top="4.5" />
<loading-placeholder />
</div>
<div v-if="person">
<Detail v-if="age" title="Age" :detail="age" />
<Detail
v-if="person"
title="Born"
:detail="person.place_of_birth ? person.place_of_birth : '(Not found)'"
/>
<Detail v-if="person.biography" title="Biography">
<Description :description="person.biography" />
</Detail>
<Detail
v-if="creditedMovies.length"
title="movies"
:detail="`Credited in ${creditedMovies.length} movies`"
>
<CastList :cast="creditedMovies" />
</Detail>
<Detail
v-if="creditedShows.length"
title="shows"
:detail="`Credited in ${creditedShows.length} shows`"
>
<CastList :cast="creditedShows" />
</Detail>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import CastList from "@/components/CastList.vue";
import Detail from "@/components/popup/Detail.vue";
import Description from "@/components/popup/Description.vue";
import LoadingPlaceholder from "@/components/ui/LoadingPlaceholder.vue";
import type { Ref, ComputedRef } from "vue";
import { getPerson, getPersonCredits } from "../../api";
import type {
IPerson,
IPersonCredits,
IMovie,
IShow
} from "../../interfaces/IList";
import { MediaTypes } from "../../interfaces/IList";
interface Props {
id: number;
}
const props = defineProps<Props>();
const ASSET_URL = "https://image.tmdb.org/t/p/";
const ASSET_SIZES = ["w500", "w780", "original"];
const person: Ref<IPerson> = ref();
const credits: Ref<IPersonCredits> = ref();
const loading: Ref<boolean> = ref(false);
const creditedMovies: Ref<Array<IMovie | IShow>> = ref([]);
const creditedShows: Ref<Array<IMovie | IShow>> = ref([]);
const poster: ComputedRef<string> = computed(() => {
if (!person.value) return "/assets/placeholder.png";
if (!person.value?.poster) return "/assets/no-image.svg";
return `${ASSET_URL}${ASSET_SIZES[0]}${person.value.poster}`;
});
const age: ComputedRef<string> = computed(() => {
if (!person.value?.birthday) return "";
const today = new Date().getFullYear();
const birthYear = new Date(person.value.birthday).getFullYear();
return `${today - birthYear} years old`;
});
function setCredits(_credits: IPersonCredits) {
credits.value = _credits;
}
function setPerson(_person: IPerson) {
person.value = _person;
}
function sortPopularity(a: IMovie | IShow, b: IMovie | IShow): number {
return a.popularity < b.popularity ? 1 : -1;
}
function alreadyExists(
item: IMovie | IShow,
pos: number,
self: Array<IMovie | IShow>
) {
const names = self.map(_item => _item.title);
return names.indexOf(item.title) === pos;
}
function personCreditedFrom(cast: Array<IMovie | IShow>): void {
creditedMovies.value = cast
.filter(credit => credit.type === MediaTypes.Movie)
.filter(alreadyExists)
.sort(sortPopularity);
creditedShows.value = cast
.filter(credit => credit.type === MediaTypes.Show)
.filter(alreadyExists)
.sort(sortPopularity);
}
function fetchPerson() {
if (!props.id) {
console.error("Unable to fetch person, missing id!"); // eslint-disable-line no-console
return;
}
getPerson(props.id)
.then(setPerson)
.then(() => getPersonCredits(person.value?.id))
.then(setCredits)
.then(() => personCreditedFrom(credits.value?.cast));
}
// On create functions
fetchPerson();
</script>
<style lang="scss" scoped>
@import "scss/loading-placeholder";
@import "scss/variables";
@import "scss/media-queries";
@import "scss/main";
section.person {
overflow: hidden;
position: relative;
padding: 40px;
background-color: var(--background-color);
@include mobile {
padding: 50px 20px 10px;
}
&:before {
content: "";
display: block;
position: absolute;
top: -130px;
left: -100px;
z-index: 1;
width: 1000px;
height: 500px;
transform: rotate(21deg);
background-color: #062541;
@include mobile {
// top: -52vw;
top: -215px;
}
}
}
header {
$duration: 0.2s;
transition: height $duration ease;
position: relative;
background-color: transparent;
display: grid;
grid-template-columns: 1fr 1fr;
height: 350px;
z-index: 2;
@include mobile {
height: 180px;
}
.info {
display: flex;
flex-direction: column;
padding: 30px;
padding-left: 0;
text-align: left;
@include mobile {
padding: 0;
}
}
h1 {
color: $green;
width: 100%;
font-weight: 500;
line-height: 1.4;
font-size: 30px;
margin-top: 0;
@include mobile {
font-size: 24px;
margin: 10px 0;
// padding: 30px 30px 30px 40px;
}
}
.known-for {
color: rgba(255, 255, 255, 0.8);
font-size: 1.2rem;
}
}
.person__poster {
display: block;
margin: auto;
width: fit-content;
border-radius: 10px;
background-color: grey;
animation: pulse 1s infinite ease-in-out;
@keyframes pulse {
0% {
background-color: rgba(165, 165, 165, 0.1);
}
50% {
background-color: rgba(165, 165, 165, 0.3);
}
100% {
background-color: rgba(165, 165, 165, 0.1);
}
}
> img {
border-radius: 10px;
width: 100%;
@include mobile {
max-width: 225px;
}
}
}
</style>

View File

@@ -1,147 +0,0 @@
<template>
<div class="change-password">
<div class="password-card">
<form class="password-form" @submit.prevent>
<seasoned-input
v-model="oldPassword"
placeholder="Current password"
icon="Keyhole"
type="password"
/>
<div class="password-generator">
<button class="generator-toggle" @click="toggleGenerator">
<IconKey class="toggle-icon" />
<span>{{
showGenerator ? "Hide" : "Generate Strong Password"
}}</span>
</button>
<div v-if="showGenerator">
<password-generator @password-generated="handleGeneratedPassword" />
</div>
</div>
<seasoned-input
v-model="newPassword"
placeholder="New password"
icon="Keyhole"
type="password"
/>
<seasoned-input
v-model="newPasswordRepeat"
placeholder="Confirm new password"
icon="Keyhole"
type="password"
/>
<seasoned-button @click="changePassword" :disabled="loading">
{{ loading ? "Updating..." : "Change Password" }}
</seasoned-button>
</form>
<seasoned-messages v-model:messages="messages" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import SeasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import PasswordGenerator from "@/components/settings/PasswordGenerator.vue";
import IconKey from "@/icons/IconKey.vue";
import type { Ref } from "vue";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
const showGenerator = ref(false);
const oldPassword: Ref<string> = ref("");
const newPassword: Ref<string> = ref("");
const newPasswordRepeat: Ref<string> = ref("");
const messages: Ref<IErrorMessage[]> = ref([]);
const loading = ref(false);
function handleGeneratedPassword(password: string) {
newPassword.value = password;
newPasswordRepeat.value = password;
}
async function changePassword(event: CustomEvent) {
try {
messages.value.push({
message: "Password change is currently disabled",
title: "Feature Disabled",
type: ErrorMessageTypes.Warning
} as IErrorMessage);
// Clear form
oldPassword.value = "";
newPassword.value = "";
newPasswordRepeat.value = "";
loading.value = false;
} catch (error) {
console.log("not valid! error:", error); // eslint-disable-line no-console
loading.value = false;
}
}
function toggleGenerator() {
showGenerator.value = !showGenerator.value;
/*
if (showGenerator.value && !generatedPassword.value) {
generateWordsPassword();
}
*/
}
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.password-card {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.password-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.generator-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
background-color: var(--background-ui);
border: 1px solid var(--background-40);
border-radius: 0.5rem;
color: $text-color;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
&:hover {
background-color: var(--background-40);
border-color: var(--highlight-color);
}
}
.toggle-icon {
width: 18px;
height: 18px;
fill: var(--highlight-color);
}
.btn-icon {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>

View File

@@ -1,114 +0,0 @@
<template>
<div>
<h3 class="settings__header">Plex account</h3>
<div v-if="!plexUserId">
<span class="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
v-model="username"
placeholder="plex username"
type="email"
/>
<seasoned-input
v-model="password"
placeholder="plex password"
type="password"
@enter="authenticatePlex"
>
</seasoned-input>
<seasoned-button @click="authenticatePlex"
>link plex account</seasoned-button
>
</form>
</div>
<div v-else>
<span class="info"
>Awesome, your account is already authenticated with plex! Enjoy viewing
your seasoned search history, plex watch history and real-time torrent
download progress.</span
>
<seasoned-button @click="unauthenticatePlex"
>un-link plex account</seasoned-button
>
</div>
<seasoned-messages v-model:messages="messages" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import { useStore } from "vuex";
import seasonedInput from "@/components/ui/SeasonedInput.vue";
import SeasonedButton from "@/components/ui/SeasonedButton.vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import type { Ref, ComputedRef } from "vue";
import { linkPlexAccount, unlinkPlexAccount } from "../../api";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
interface Emit {
(e: "reload");
}
const username: Ref<string> = ref("");
const password: Ref<string> = ref("");
const messages: Ref<IErrorMessage[]> = ref([]);
const store = useStore();
const emit = defineEmits<Emit>();
const plexUserId: ComputedRef<boolean> = computed(
() => store.getters["user/plexUserId"]
);
async function authenticatePlex() {
const { success, message } = await linkPlexAccount(
username.value,
password.value
);
if (success) {
username.value = "";
password.value = "";
emit("reload");
}
messages.value.push({
type: success ? ErrorMessageTypes.Success : ErrorMessageTypes.Error,
title: success ? "Authenticated with plex" : "Something went wrong",
message
} as IErrorMessage);
}
async function unauthenticatePlex() {
const response = await unlinkPlexAccount();
if (response?.success) {
emit("reload");
}
messages.value.push({
type: response.success
? ErrorMessageTypes.Success
: ErrorMessageTypes.Error,
title: response.success
? "Unlinked plex account "
: "Something went wrong",
message: response.message
} as IErrorMessage);
}
</script>
<style lang="scss" scoped>
.info {
display: block;
margin-bottom: 25px;
}
</style>

View File

@@ -1,72 +0,0 @@
<template>
<div class="danger-zone">
<h3 class="danger-zone__title">{{ title }}</h3>
<p class="danger-zone__description">
{{ description }}
</p>
<button class="danger-zone__button" @click="$emit('action')">
{{ buttonText }}
</button>
</div>
</template>
<script setup lang="ts">
interface Props {
title: string;
description: string;
buttonText: string;
}
interface Emit {
(e: "action"): void;
}
defineProps<Props>();
defineEmits<Emit>();
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.danger-zone {
padding: 1.25rem;
background: rgba(220, 48, 35, 0.1);
border: 1px solid var(--color-error-highlight);
border-radius: 0.5rem;
@include mobile-only {
padding: 1rem;
}
&__title {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-error-highlight);
}
&__description {
margin: 0 0 1rem 0;
font-size: 0.875rem;
color: var(--text-color-70);
line-height: 1.5;
}
&__button {
padding: 0.625rem 1.25rem;
background: var(--color-error);
color: white;
border: 1px solid var(--color-error-highlight);
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: var(--color-error-highlight);
}
}
}
</style>

View File

@@ -1,53 +0,0 @@
<template>
<div class="data-export">
<div class="export-options">
<!-- Request History Card -->
<RequestHistory :data="requestStats" />
<!-- Export Data Card -->
<ExportSection :data="requestStats" />
<!-- Local Storage Items -->
<StorageManager />
<!-- Delete Account -->
<DangerZoneAction
title="Delete Account"
description="Permanently delete your account and all associated data. This action cannot be undone."
button-text="Delete My Account"
@action="confirmDelete"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import StorageManager from "./StorageManager.vue";
import ExportSection from "./ExportSection.vue";
import RequestHistory from "./RequestHistory.vue";
import DangerZoneAction from "./DangerZoneAction.vue";
const requestStats = ref({
total: 45,
approved: 38,
pending: 7
});
function confirmDelete() {
const confirmed = confirm(
"Are you sure you want to *permanently delete* your account and all associated data? This action cannot be undone."
);
if (!confirmed) return;
}
</script>
<style lang="scss" scoped>
.export-options {
display: flex;
flex-direction: column;
gap: 0.65rem;
gap: 2rem;
}
</style>

View File

@@ -1,125 +0,0 @@
<template>
<div class="settings-section-card">
<div class="settings-section-header">
<h2>Export Your Data</h2>
<p>
Download a copy of your account data including requests, watch history,
and preferences.
</p>
</div>
<!-- Export to JSON & CSV section -->
<div class="export-actions">
<button
class="export-btn"
@click="() => exportData('json')"
:disabled="exporting"
>
<IconActivity v-if="exporting" class="spin" />
<span v-else>Export as JSON</span>
</button>
<button
class="export-btn"
@click="() => exportData('csv')"
:disabled="exporting"
>
<IconActivity v-if="exporting" class="spin" />
<span v-else>Export as CSV</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
interface Props {
data: any;
}
const props = defineProps<Props>();
const exporting = ref(false);
async function exportData(format: "json" | "csv") {
exporting.value = true;
// Mock export
await new Promise(resolve => setTimeout(resolve, 1500));
const data = {
username: "user123",
requests: props?.data,
exportDate: new Date().toISOString()
};
const blob = new Blob(
[format === "json" ? JSON.stringify(data, null, 2) : convertToCSV(data)],
{ type: format === "json" ? "application/json" : "text/csv" }
);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `seasoned-data-export.${format}`;
link.click();
URL.revokeObjectURL(url);
exporting.value = false;
}
function convertToCSV(data: any): string {
return `Username,Total Requests,Approved,Pending,Export Date\n${data.username},${data.requests.total},${data.requests.approved},${data.requests.pending},${data.exportDate}`;
}
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
@import "scss/shared-settings";
.export-actions {
display: flex;
gap: 0.55rem;
@include mobile-only {
flex-direction: column;
gap: 0.5rem;
}
}
.export-btn {
flex: 1;
padding: 0.55rem 0.85rem;
background-color: var(--highlight-color);
color: white;
border: none;
border-radius: 0.25rem;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
&:hover:not(:disabled) {
background-color: var(--color-green-90);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
svg {
width: 16px;
height: 16px;
fill: white;
&.spin {
animation: spin 1s linear infinite;
}
}
}
</style>

View File

@@ -1,597 +0,0 @@
<template>
<div class="password-generator">
<div class="generator-panel">
<div class="generator-tabs">
<button
:class="['tab', { 'tab--active': mode === 'words' }]"
@click="mode = 'words'"
>
Passphrase
</button>
<button
:class="['tab', { 'tab--active': mode === 'chars' }]"
@click="mode = 'chars'"
>
Random
</button>
</div>
<div v-if="mode === 'words'" class="generator-content">
<div class="generator-header">
<h4>Passphrase Generator</h4>
<p>Create a memorable password using random words</p>
</div>
<div class="password-display" @click="copyPassword">
<span class="password-text password-text--mono">{{
generatedPassword
}}</span>
<button
class="copy-btn"
:title="copied ? 'Copied!' : 'Click to copy'"
>
{{ copied ? "✓" : "📋" }}
</button>
</div>
<div class="generator-options">
<div class="option-row">
<div class="slider-header">
<label>Words</label>
<span class="slider-value">{{ wordCount }}</span>
</div>
<input
v-model.number="wordCount"
type="range"
min="3"
max="7"
class="slider"
@input="generateWordsPassword"
/>
<div class="slider-labels">
<span>3</span>
<span>7</span>
</div>
</div>
</div>
</div>
<div v-else class="generator-content">
<div class="generator-header">
<h4>Random Password Generator</h4>
<p>Generate a secure random password</p>
</div>
<div class="password-display" @click="copyPassword">
<span class="password-text password-text--mono">{{
generatedPassword
}}</span>
<button
class="copy-btn"
:title="copied ? 'Copied!' : 'Click to copy'"
>
{{ copied ? "✓" : "📋" }}
</button>
</div>
<div class="generator-options">
<div class="option-row">
<div class="slider-header">
<label>Length</label>
<span class="slider-value">{{ charLength }}</span>
</div>
<input
v-model.number="charLength"
type="range"
min="12"
max="46"
class="slider"
@input="generateCharsPassword"
/>
<div class="slider-labels">
<span>12</span>
<span>46</span>
</div>
</div>
<div class="option-row checkbox-row">
<label>
<input
v-model="includeUppercase"
type="checkbox"
@change="generateCharsPassword"
/>
Uppercase (A-Z)
</label>
<label>
<input
v-model="includeLowercase"
type="checkbox"
@change="generateCharsPassword"
/>
Lowercase (a-z)
</label>
<label>
<input
v-model="includeNumbers"
type="checkbox"
@change="generateCharsPassword"
/>
Numbers (0-9)
</label>
<label>
<input
v-model="includeSymbols"
type="checkbox"
@change="generateCharsPassword"
/>
Symbols (!@#$)
</label>
</div>
</div>
</div>
<div class="generator-actions">
<button class="action-btn action-btn--secondary" @click="regenerate">
<IconActivity class="btn-icon" />
Regenerate
</button>
<button class="action-btn action-btn--primary" @click="usePassword">
Use This Password
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import IconActivity from "@/icons/IconActivity.vue";
import { useRandomWords } from "@/composables/useRandomWords";
interface Emit {
(e: "passwordGenerated", password: string): void;
}
const emit = defineEmits<Emit>();
const mode = ref<"words" | "chars">("words");
const generatedPassword = ref("");
const copied = ref(false);
// Words mode options
const wordCount = ref(4);
const separator = ref("-");
// Chars mode options
const charLength = ref(16);
const includeUppercase = ref(true);
const includeLowercase = ref(true);
const includeNumbers = ref(true);
const includeSymbols = ref(true);
const { getRandomWords } = useRandomWords();
async function generateWordsPassword() {
const words = await getRandomWords(wordCount.value);
const password = words.join(separator.value);
generatedPassword.value = password;
}
function generateCharsPassword() {
let charset = "";
if (includeUppercase.value) charset += "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
if (includeLowercase.value) charset += "abcdefghijklmnopqrstuvwxyz";
if (includeNumbers.value) charset += "0123456789";
if (includeSymbols.value) charset += "!@#$%^&*()_+-=[]{}|;:,.<>?";
if (charset === "") charset = "abcdefghijklmnopqrstuvwxyz";
let password = "";
for (let i = 0; i < charLength.value; i++) {
password += charset.charAt(Math.floor(Math.random() * charset.length));
}
generatedPassword.value = password;
}
async function regenerate() {
if (mode.value === "words") {
await generateWordsPassword();
} else {
generateCharsPassword();
}
}
async function copyPassword() {
try {
await navigator.clipboard.writeText(generatedPassword.value);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
function usePassword() {
emit("passwordGenerated", generatedPassword.value);
// TODO: emit
// showGenerator.value = false;
}
watch(mode, async () => {
if (mode.value === "words") {
await generateWordsPassword();
} else {
generateCharsPassword();
}
});
onMounted(generateWordsPassword);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.password-generator {
margin-bottom: 1rem;
}
.generator-panel {
margin-top: 0.75rem;
padding: 1rem;
background-color: var(--background-ui);
border-radius: 0.5rem;
border: 1px solid var(--background-40);
@include mobile-only {
padding: 0.75rem;
}
}
.generator-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab {
flex: 1;
padding: 0.65rem 1rem;
background-color: transparent;
border: 1px solid var(--background-40);
border-radius: 0.25rem;
color: $text-color-70;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
&:hover {
background-color: var(--background-40);
}
&--active {
background-color: var(--highlight-color);
border-color: var(--highlight-color);
color: $white;
}
}
.generator-content {
margin-bottom: 1rem;
}
.generator-header {
margin-bottom: 0.75rem;
h4 {
margin: 0 0 0.15rem 0;
font-size: 0.95rem;
font-weight: 500;
color: $text-color;
line-height: 1.3;
}
p {
margin: 0;
font-size: 0.8rem;
color: $text-color-70;
line-height: 1.3;
}
}
.password-display {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background-color: var(--background-color);
border: 2px solid var(--highlight-color);
border-radius: 0.5rem;
margin-bottom: 0.75rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: var(--background-40);
}
@include mobile-only {
padding: 0.6rem;
}
}
.password-text {
flex: 1;
font-size: 1.8rem;
font-weight: 500;
color: var(--highlight-color);
user-select: all;
word-break: break-all;
word-break: break-word;
-webkit-hyphens: auto;
hyphens: auto;
@include mobile-only {
font-size: 0.95rem;
}
&--mono {
font-family: "Courier New", monospace;
@include mobile-only {
font-size: 0.85rem;
}
}
}
.copy-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
padding: 0.25rem;
transition: transform 0.2s;
&:hover {
transform: scale(1.1);
}
}
.generator-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.option-row {
display: flex;
flex-direction: column;
gap: 0.75rem;
label {
font-size: 0.95rem;
color: $text-color;
font-weight: 600;
line-height: 1.2;
}
&.checkbox-row {
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
@include mobile-only {
flex-direction: column;
gap: 0.6rem;
}
label {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 400;
cursor: pointer;
font-size: 0.85rem;
input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
}
}
}
}
.slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.slider-value {
font-size: 1.25rem;
font-weight: 700;
color: var(--highlight-color);
min-width: 2.5rem;
text-align: right;
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: $text-color-50;
margin-top: 0.25rem;
padding: 0 0.25rem;
}
.slider {
width: 100%;
height: 10px;
border-radius: 5px;
background: var(--background-40);
outline: none;
appearance: none;
cursor: pointer;
transition: background 0.2s;
margin: 0.5rem 0;
@include mobile-only {
height: 12px;
}
&:hover {
background: var(--background-40);
}
&::-webkit-slider-thumb {
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--highlight-color);
cursor: grab;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
margin-top: -7px;
@include mobile-only {
width: 28px;
height: 28px;
margin-top: -8px;
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
&:active {
cursor: grabbing;
transform: scale(1.05);
}
}
&::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--highlight-color);
cursor: grab;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s;
@include mobile-only {
width: 28px;
height: 28px;
}
&:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
}
&:active {
cursor: grabbing;
transform: scale(1.05);
}
}
&::-webkit-slider-runnable-track {
height: 10px;
border-radius: 5px;
@include mobile-only {
height: 12px;
}
}
&::-moz-range-track {
height: 10px;
border-radius: 5px;
background: var(--background-40);
@include mobile-only {
height: 12px;
}
}
}
.separator-input {
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;
font-family: "Courier New", monospace;
text-align: center;
&:focus {
outline: none;
border-color: var(--highlight-color);
}
&::placeholder {
color: $text-color-50;
font-family: inherit;
}
}
.generator-actions {
display: flex;
gap: 0.5rem;
@include mobile-only {
flex-direction: column;
gap: 0.5rem;
}
}
.action-btn {
flex: 1;
padding: 0.6rem 1rem;
border: none;
border-radius: 0.25rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
&--secondary {
background-color: var(--background-color);
color: $text-color;
border: 1px solid var(--background-40);
&:hover {
background-color: var(--background-40);
}
}
&--primary {
background-color: var(--highlight-color);
color: $white;
&:hover {
background-color: var(--color-green-90);
}
}
}
.btn-icon {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>

View File

@@ -1,349 +0,0 @@
<template>
<div class="plex-settings">
<!-- Unconnected state -->
<PlexAuthButton
v-if="!showPlexInformation"
@auth-success="handleAuthSuccess"
@auth-error="handleAuthError"
/>
<!-- Connected state -->
<div v-else class="plex-connected">
<PlexProfileCard
v-if="plexUsername"
:username="plexUsername"
:userData="plexUserData"
/>
<PlexLibraryStats
:movies="libraryStats?.movies"
:shows="libraryStats?.['tv shows']"
:music="libraryStats?.music"
:watchtime="libraryStats?.watchtime || 0"
:loading="syncingLibrary"
@open-library="showLibraryDetails"
/>
<PlexServerInfo
:serverName="plexServer"
:lastSync="lastSync"
:syncing="syncingServer"
@sync="syncLibrary"
@unlink="() => (showUnlinkModal = true)"
/>
</div>
<!-- Messages -->
<SeasonedMessages v-model:messages="messages" />
<!-- Unlink Confirmation Modal -->
<PlexUnlinkModal
v-if="showUnlinkModal"
@confirm="unauthenticatePlex"
@cancel="() => (showUnlinkModal = false)"
/>
<!-- Library Details Modal -->
<PlexLibraryModal
v-if="showLibraryModal && selectedLibrary"
:libraryType="selectedLibrary"
:details="libraryStats[selectedLibrary]"
:serverUrl="plexServerUrl"
:serverMachineId="plexMachineId"
@close="closeLibraryModal"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import SeasonedMessages from "@/components/ui/SeasonedMessages.vue";
import PlexAuthButton from "@/components/plex/PlexAuthButton.vue";
import PlexProfileCard from "@/components/plex/PlexProfileCard.vue";
import PlexLibraryStats from "@/components/plex/PlexLibraryStats.vue";
import PlexServerInfo from "@/components/plex/PlexServerInfo.vue";
import PlexUnlinkModal from "@/components/plex/PlexUnlinkModal.vue";
import PlexLibraryModal from "@/components/plex/PlexLibraryModal.vue";
import { usePlexAuth } from "@/composables/usePlexAuth";
import {
fetchPlexServers,
fetchPlexUserData,
fetchLibraryDetails
} from "@/composables/usePlexApi";
import type { Ref } from "vue";
import { ErrorMessageTypes } from "../../interfaces/IErrorMessage";
import type { IErrorMessage } from "../../interfaces/IErrorMessage";
const messages: Ref<IErrorMessage[]> = ref([]);
const syncingServer = ref(false);
const syncingLibrary = ref(false);
const showUnlinkModal = ref(false);
const plexUsername = ref<string>("");
const plexUserData = ref<any>(null);
const showPlexInformation = ref<boolean>(false);
const hasLocalStorageData = ref<boolean>(false);
const showLibraryModal = ref<boolean>(false);
const selectedLibrary = ref<string>("");
const plexServer = ref("");
const plexServerUrl = ref("");
const plexMachineId = ref("");
const lastSync = ref(sessionStorage.getItem("plex_library_last_sync"));
const libraryStats = ref({
movies: 0,
shows: 0,
music: 0,
watchtime: 0
});
const emit = defineEmits<{
(e: "reload"): void;
}>();
// Composables
const { getPlexAuthCookie, setPlexAuthCookie, cleanup } = usePlexAuth();
// ----- Connection check -----
function checkPlexConnection() {
const authToken = getPlexAuthCookie();
showPlexInformation.value = !!authToken;
return showPlexInformation.value;
}
// ----- Library loading -----
async function loadPlexServer() {
// return cached value from sessionStorage if exists
const cacheKey = "plex_server_data";
const cachedData = sessionStorage.getItem(cacheKey);
if (cachedData) {
const server = JSON.parse(cachedData);
plexServer.value = server?.name;
plexServerUrl.value = server?.url;
plexMachineId.value = server?.machineIdentifier;
return;
}
// get token from cookie
const authToken = getPlexAuthCookie();
if (!authToken) return;
// make api call for data
syncingServer.value = true;
const server = await fetchPlexServers(authToken);
if (server) {
// set server name & id
plexServer.value = server?.name;
plexServerUrl.value = server?.url;
plexMachineId.value = server?.machineIdentifier;
// cache in sessionStorage
sessionStorage.setItem(cacheKey, JSON.stringify(server));
// set last-sync date
const now = new Date().toLocaleString();
lastSync.value = now;
sessionStorage.setItem("plex_library_last_sync", now);
} else {
console.log("unable to load plex server informmation");
}
syncingServer.value = false;
}
// ----- User data loading -----
async function loadPlexUserData() {
// return cached value from sessionStorage if exists
const cacheKey = "plex_user_data";
const cachedData = sessionStorage.getItem(cacheKey);
hasLocalStorageData.value = !!cachedData;
if (cachedData) {
plexUserData.value = JSON.parse(cachedData);
plexUsername.value = plexUserData.value.username;
return;
}
// get token from cookie
const authToken = getPlexAuthCookie();
if (!authToken) return;
// make api call for data
const userData = await fetchPlexUserData(authToken);
if (userData) {
// set plex user data
plexUserData.value = userData;
plexUsername.value = userData?.username;
// cache in sessionStorage
sessionStorage.setItem(cacheKey, JSON.stringify(userData));
} else {
console.log("unable to load user data from plex");
}
}
// ----- Load plex libary details -----
async function loadPlexLibraries() {
// return cached value from sessionStorage if exists
const cacheKey = "plex_library_data";
const cachedData = sessionStorage.getItem(cacheKey);
hasLocalStorageData.value = !!cachedData;
if (cachedData) {
libraryStats.value = JSON.parse(cachedData);
return;
}
// get token from cookie
const authToken = getPlexAuthCookie();
if (!authToken) return;
// make api call for data
syncingLibrary.value = true;
const library = await fetchLibraryDetails();
if (library) {
libraryStats.value = library;
// cache in sessionStorage
sessionStorage.setItem(cacheKey, JSON.stringify(library));
} else {
console.log("unable to load plex library details");
}
syncingLibrary.value = false;
}
// ----- OAuth flow (handlers for PlexAuthButton events) -----
async function handleAuthSuccess(authToken: string) {
setPlexAuthCookie(authToken);
checkPlexConnection();
const success = await loadAll();
if (success) {
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Authenticated with Plex",
message: "Successfully connected your Plex account"
} as IErrorMessage);
} else {
console.error("[PlexSettings] Error in handleAuthSuccess:");
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Authentication failed",
message: "An error occurred while connecting to Plex"
} as IErrorMessage);
}
}
function handleAuthError(errorMessage: string) {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Authentication failed",
message: errorMessage
} as IErrorMessage);
}
// ----- Unlink flow -----
async function unauthenticatePlex() {
showUnlinkModal.value = false;
sessionStorage.removeItem("plex_user_data");
sessionStorage.removeItem("plex_server_data");
sessionStorage.removeItem("plex_library_data");
sessionStorage.removeItem("plex_library_last_sync");
document.cookie =
"plex_auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Strict";
plexUserData.value = null;
plexUsername.value = "";
showPlexInformation.value = false;
emit("reload");
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Unlinked Plex account",
message: "All browser storage has been clear of plex account"
} as IErrorMessage);
}
// ----- Library modal -----
function showLibraryDetails(type: string) {
selectedLibrary.value = type;
document.getElementsByTagName("body")[0].classList.add("no-scroll");
showLibraryModal.value = true;
}
function closeLibraryModal() {
showLibraryModal.value = false;
document.getElementsByTagName("body")[0].classList.remove("no-scroll");
selectedLibrary.value = "";
}
// ----- Sync -----
async function syncLibrary() {
const authToken = getPlexAuthCookie();
if (!authToken) {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Sync failed",
message: "No authentication token found"
} as IErrorMessage);
return;
}
sessionStorage.removeItem("plex_user_data");
sessionStorage.removeItem("plex_server_data");
sessionStorage.removeItem("plex_library_data");
const success = await loadAll();
if (success) {
messages.value.push({
type: ErrorMessageTypes.Success,
title: "Library synced",
message: "Your Plex library has been successfully synced"
} as IErrorMessage);
} else {
messages.value.push({
type: ErrorMessageTypes.Error,
title: "Sync failed",
message: "An error occurred while syncing your library"
} as IErrorMessage);
}
}
// ---- Helper load all ----
async function loadAll() {
let success = false;
try {
await Promise.all([
loadPlexServer(),
loadPlexUserData(),
loadPlexLibraries()
]);
success = true;
} catch (error) {
console.log("loadall error, some info might be missing");
}
checkPlexConnection();
return success;
}
// ---- Lifecycle functions ----
onMounted(loadAll);
onUnmounted(() => {
cleanup();
});
</script>
<style scoped>
.plex-settings {
max-width: 800px;
}
.plex-connected {
display: flex;
flex-direction: column;
gap: 24px;
}
</style>

View File

@@ -1,233 +0,0 @@
<template>
<div class="profile-hero">
<div class="profile-hero__main">
<div class="profile-hero__avatar">
<div class="avatar-large">{{ userInitials }}</div>
</div>
<div class="profile-hero__info">
<h1 class="profile-hero__name">{{ username }}</h1>
<span :class="['profile-hero__badge', `badge--${userRole}`]">
<a v-if="userRole === 'admin'" href="/admin">{{ userRole }}</a>
<span v-else>{{ userRole }}</span>
</span>
<p class="profile-hero__member">Member since {{ memberSince }}</p>
</div>
</div>
<div class="profile-hero__stats">
<div class="stat-large">
<span class="stat-large__value">{{ stats.totalRequests }}</span>
<span class="stat-large__label">Requests</span>
</div>
<div class="stat-divider"></div>
<div class="stat-large">
<span class="stat-large__value">{{ stats.magnetsAdded }}</span>
<span class="stat-large__label">Magnets Added</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useStore } from "vuex";
const store = useStore();
const username = computed(() => store.getters["user/username"] || "User");
const userRole = computed(() =>
store.getters["user/admin"] ? "admin" : "user"
);
const userInitials = computed(() => {
return username.value.slice(0, 2).toUpperCase();
});
const memberSince = computed(() => {
const date = new Date();
date.setMonth(date.getMonth() - 6);
return date.toLocaleDateString("en-US", {
month: "short",
year: "numeric"
});
});
const stats = {
totalRequests: 45,
magnetsAdded: 127
};
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
.profile-hero {
background-color: var(--background-color-secondary);
border-radius: 0.75rem;
padding: 1.5rem;
border: 1px solid var(--background-40);
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
@include mobile-only {
flex-direction: column;
padding: 1.5rem 1.25rem;
border-radius: 0.5rem;
text-align: center;
gap: 1rem;
}
&__main {
display: flex;
align-items: center;
gap: 1.5rem;
@include mobile-only {
flex-direction: column;
gap: 0.75rem;
}
}
&__avatar {
flex-shrink: 0;
}
&__info {
display: flex;
flex-direction: column;
gap: 0.35rem;
@include mobile-only {
align-items: center;
}
}
&__name {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.1;
@include mobile-only {
font-size: 1.5rem;
}
}
&__badge {
display: inline-block;
padding: 0.25rem 0.7rem;
border-radius: 2rem;
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
width: fit-content;
@include mobile-only {
padding: 0.2rem 0.6rem;
font-size: 0.7rem;
}
&.badge--admin {
background-color: var(--color-warning);
color: black;
}
&.badge--user {
background-color: var(--background-40);
}
}
&__member {
margin: 0;
font-size: 0.85rem;
color: var(--text-color-70);
@include mobile-only {
font-size: 0.8rem;
}
}
&__stats {
display: flex;
align-items: center;
gap: 1.75rem;
padding-left: 1.75rem;
border-left: 1px solid var(--background-40);
@include mobile-only {
width: 100%;
padding: 1rem 0 0 0;
border-left: none;
border-top: 1px solid var(--background-40);
justify-content: center;
gap: 1.25rem;
}
}
}
.avatar-large {
width: 70px;
height: 70px;
border-radius: 50%;
background: linear-gradient(
135deg,
var(--highlight-color),
var(--color-green-70)
);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
font-weight: 700;
color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
@include mobile-only {
width: 80px;
height: 80px;
font-size: 2rem;
}
}
.stat-large {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
&__value {
font-size: 1.75rem;
font-weight: 700;
color: var(--highlight-color);
line-height: 1;
@include mobile-only {
font-size: 1.75rem;
}
}
&__label {
font-size: 0.75rem;
color: var(--text-color-70);
text-transform: uppercase;
font-weight: 500;
letter-spacing: 0.5px;
@include mobile-only {
font-size: 0.75rem;
}
}
}
.stat-divider {
width: 1px;
height: 45px;
background-color: var(--background-40);
@include mobile-only {
height: 45px;
}
}
</style>

View File

@@ -1,103 +0,0 @@
<template>
<div class="export-card">
<div class="settings-section-header">
<h2>Request History</h2>
<p>View and download your complete request history.</p>
</div>
<div class="stats-grid">
<div class="stat-mini">
<span class="stat-mini__value">{{ data.total }}</span>
<span class="stat-mini__label">Total</span>
</div>
<div class="stat-mini">
<span class="stat-mini__value">{{ data.approved }}</span>
<span class="stat-mini__label">Approved</span>
</div>
<div class="stat-mini">
<span class="stat-mini__value">{{ data.pending }}</span>
<span class="stat-mini__label">Pending</span>
</div>
</div>
<button class="view-btn" @click="viewHistory">View Full History</button>
</div>
</template>
<script setup lang="ts">
import { defineProps } from "vue";
import { useRouter } from "vue-router";
interface Props {
data: any;
}
defineProps<Props>();
const router = useRouter();
function viewHistory() {
router.push({ name: "profile" });
}
</script>
<style lang="scss" scoped>
@import "scss/media-queries";
@import "scss/shared-settings";
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-bottom: 0.65rem;
}
.stat-mini {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0.4rem;
background-color: var(--background-color);
border-radius: 0.25rem;
@include mobile-only {
padding: 0.45rem 0.35rem;
}
&__value {
font-size: 1.2rem;
font-weight: 600;
color: var(--highlight-color);
@include mobile-only {
font-size: 1.1rem;
}
}
&__label {
font-size: 0.7rem;
text-transform: uppercase;
margin-top: 0.15rem;
@include mobile-only {
font-size: 0.65rem;
}
}
}
.view-btn {
width: 100%;
padding: 0.55rem 0.85rem;
background-color: var(--background-color);
border: 1px solid var(--background-40);
border-radius: 0.25rem;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
color: var(--text-color);
&:hover {
background-color: var(--background-40);
border-color: var(--highlight-color);
}
}
</style>

View File

@@ -1,46 +0,0 @@
<template>
<div class="security-settings">
<div class="security-settings__intro">
<h2 class="security-settings__title">Security</h2>
<p class="security-settings__description">
Keep your account safe by using a strong, unique password. We recommend
using a passphrase or generated password that's hard to guess.
</p>
</div>
<change-password />
</div>
</template>
<script setup lang="ts">
import ChangePassword from "@/components/profile/ChangePassword.vue";
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.security-settings {
&__intro {
margin-bottom: 1rem;
@include mobile-only {
margin-bottom: 0.85rem;
}
}
&__title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.3;
}
&__description {
margin: 0;
font-size: 0.95rem;
line-height: 1.6;
color: var(--text-color-70);
}
}
</style>

View File

@@ -1,215 +0,0 @@
<template>
<div class="storage-manager">
<StorageSectionBrowser
:sections="storageSections"
@clear-item="clearItem"
/>
<DangerZoneAction
title="Clear All Browser Data"
description="Remove all locally stored data at once. This includes preferences, history, and cached information."
button-text="Clear All Data"
@action="clearAllData"
/>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from "vue";
import IconCookie from "@/icons/IconCookie.vue";
import IconDatabase from "@/icons/IconDatabase.vue";
import IconTimer from "@/icons/IconTimer.vue";
import StorageSectionBrowser from "./StorageSectionBrowser.vue";
import DangerZoneAction from "./DangerZoneAction.vue";
import { formatBytes } from "../../utils";
interface StorageItem {
key: string;
description: string;
size: string;
type: "local" | "session" | "cookie";
}
const notifications: {
success: (options: {
title: string;
description?: string;
timeout?: number;
}) => void;
error: (options: {
title: string;
description?: string;
timeout?: number;
}) => void;
} = inject("notifications");
const dict = {
commandPalette_stats: "Usage statistics for command palette navigation",
"theme-preference": "Your selected color theme",
plex_user_data: "Cached Plex account information",
plex_library_data: "Cached Plex library details per section",
plex_server_data: "Cached Plex server information",
plex_library_last_sync: "UTC time string for last synced Plex data",
plex_auth_token: "Authorized token from Plex.tv",
authorization: "This sites user login token"
};
const storageItems = computed<StorageItem[]>(() => {
const items: StorageItem[] = [];
// local storage
Object.keys(localStorage).map(key => {
items.push({
key,
description: dict[key] ?? "",
size: formatBytes(localStorage[key]?.length || 0),
type: "local"
});
});
// session storage
Object.keys(sessionStorage).map(key => {
items.push({
key,
description: dict[key] ?? "",
size: formatBytes(sessionStorage[key]?.length || 0),
type: "session"
});
});
// cookies
if (document.cookie) {
document.cookie.split(";").forEach(cookie => {
const [key, _] = cookie.trim().split("=");
if (key) {
items.push({
key,
description: dict[key] ?? "",
size: formatBytes(cookie.length || 0),
type: "cookie"
});
}
});
}
return items;
});
const getTotalSize = (items: StorageItem[]) => {
const totalBytes = items.reduce((acc, item) => {
const match = item.size.match(/^([\d.]+)\s*(\w+)$/);
if (!match) return acc;
const value = parseFloat(match[1]);
const unit = match[2];
return (
acc +
(unit === "KB"
? value * 1024
: unit === "MB"
? value * 1024 * 1024
: value)
);
}, 0);
return formatBytes(totalBytes);
};
const storageSections = computed(() => [
{
type: "local" as const,
title: "LocalStorage",
iconComponent: IconDatabase,
description:
"LocalStorage keeps data permanently on your device, even after closing your browser. It's used to remember your preferences and settings between visits.",
items: storageItems.value.filter(item => item.type === "local"),
get totalSize() {
return getTotalSize(this.items);
}
},
{
type: "session" as const,
title: "SessionStorage",
iconComponent: IconTimer,
description:
"SessionStorage keeps data temporarily while you browse. It's automatically cleared when you close your browser tab or window.",
items: storageItems.value.filter(item => item.type === "session"),
get totalSize() {
return getTotalSize(this.items);
}
},
{
type: "cookie" as const,
title: "Cookies",
iconComponent: IconCookie,
description:
"Cookies are small text files stored by your browser. They can be temporary (session cookies) or persistent, and are often used for authentication and tracking your activity.",
items: storageItems.value.filter(item => item.type === "cookie"),
get totalSize() {
return getTotalSize(this.items);
}
}
]);
function clearItem(key: string, type: "local" | "session" | "cookie") {
try {
if (type === "local") {
localStorage.removeItem(key);
} else if (type === "session") {
sessionStorage.removeItem(key);
} else if (type === "cookie") {
document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
}
notifications.success({
title: "Data Cleared",
description: `${key} has been cleared`,
timeout: 3000
});
// Force re-render
storageItems.value;
} catch (error) {
notifications.error({
title: "Error",
description: `Failed to clear ${key}`,
timeout: 5000
});
}
}
function clearAllData() {
const confirmed = confirm(
"Are you sure you want to clear all locally stored data? This action cannot be undone."
);
if (!confirmed) return;
try {
localStorage.clear();
sessionStorage.clear();
document.cookie.split(";").forEach(cookie => {
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
});
notifications.success({
title: "All Data Cleared",
description: "All locally stored data has been removed",
timeout: 3000
});
} catch (error) {
notifications.error({
title: "Error",
description: "Failed to clear all data",
timeout: 5000
});
}
}
</script>
<style lang="scss" scoped>
.storage-manager {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -1,385 +0,0 @@
<template>
<div class="browser-storage">
<div class="settings-section-header">
<h2>Browser Storage</h2>
<p>
Your browser stores data locally to make this site faster and remember
your settings. View what's saved on this device and remove items
anytime.
</p>
</div>
<div class="storage-sections">
<div
v-for="section in sections"
:key="section.type"
:class="`storage-section storage-section--${section.type}`"
>
<button
class="storage-section__header"
@click="
expandedSections[section.type] = !expandedSections[section.type]
"
>
<div class="storage-section__header-content">
<component :is="section.iconComponent" class="section-icon" />
<h3 class="storage-section__title">{{ section.title }}</h3>
<span class="storage-section__count">{{
section.items.length
}}</span>
<span class="storage-section__size">{{ section.totalSize }}</span>
</div>
<div class="chevron-container">
<transition name="fade">
<IconExpandVertical
v-if="!expandedSections[section.type]"
key="expand"
class="storage-section__chevron"
/>
<IconShrinkVertical
v-else
key="shrink"
class="storage-section__chevron"
/>
</transition>
</div>
</button>
<div
v-if="expandedSections[section.type]"
class="storage-section__content"
>
<p class="storage-section__description">{{ section.description }}</p>
<div class="storage-items">
<div
v-for="item in section.items"
:key="item.key"
:class="`storage-item storage-item--${section.type}`"
>
<component :is="section.iconComponent" class="type-icon" />
<div class="storage-item__info">
<h4 class="storage-item__title">{{ item.key }}</h4>
<p class="storage-item__description">
<span v-if="item.description">{{ item.description }} · </span>
<span class="storage-item__size">{{ item.size }}</span>
</p>
</div>
<button
class="storage-item__delete"
@click="$emit('clear-item', item.key, section.type)"
:title="`Clear ${item.key}`"
>
<IconClose />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import IconClose from "@/icons/IconClose.vue";
import IconExpandVertical from "@/icons/IconExpandVertical.vue";
import IconShrinkVertical from "@/icons/IconShrinkVertical.vue";
interface StorageItem {
key: string;
description: string;
size: string;
type: "local" | "session" | "cookie";
}
interface StorageSection {
type: "local" | "session" | "cookie";
title: string;
description: string;
iconComponent: any;
items: StorageItem[];
totalSize: string;
}
defineProps<{
sections: StorageSection[];
}>();
defineEmits<{
"clear-item": [key: string, type: "local" | "session" | "cookie"];
}>();
const expandedSections = ref<Record<string, boolean>>({
local: false,
session: false,
cookie: false
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
@import "scss/shared-settings";
.browser-storage {
&__intro {
margin-bottom: 2rem;
}
}
.storage-sections {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.storage-section {
border-radius: 0.5rem;
background: var(--background-ui);
overflow: hidden;
border: 2px solid transparent;
transition: border-color 0.2s ease;
&--local {
border-color: rgba(139, 92, 246, 0.2);
.section-icon,
.type-icon {
stroke: #8b5cf6;
}
}
&--session {
border-color: rgba(245, 158, 11, 0.2);
.section-icon,
.type-icon {
stroke: #f59e0b;
}
}
&--cookie {
border-color: rgba(236, 72, 153, 0.2);
.section-icon,
.type-icon {
fill: #ec4899;
}
}
&__header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: var(--background-40);
}
}
&__header-content {
display: flex;
align-items: center;
gap: 0.75rem;
.section-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background: var(--background-40);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-70);
}
&__size {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color-50);
font-family: monospace;
}
&__chevron {
position: absolute;
width: 20px;
height: 20px;
fill: var(--text-color-70);
}
&__content {
border-top: 1px solid var(--background-40);
}
&__description {
margin: 0;
padding: 1rem 1.25rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-color-70);
background: var(--background-color);
font-style: italic;
}
}
.storage-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem 1rem;
}
.storage-item {
display: grid;
grid-template-columns: auto 1fr auto;
background: var(--background-color);
border-radius: 0.25rem;
overflow: hidden;
transition: all 0.2s ease;
border-left: 3px solid;
&--local {
border-color: #8b5cf6;
background: linear-gradient(
90deg,
rgba(139, 92, 246, 0.1),
var(--background-color)
);
}
&--session {
border-color: #f59e0b;
background: linear-gradient(
90deg,
rgba(245, 158, 11, 0.1),
var(--background-color)
);
}
&--cookie {
border-color: #ec4899;
background: linear-gradient(
90deg,
rgba(236, 72, 153, 0.1),
var(--background-color)
);
}
&:hover .storage-item__delete {
background: var(--color-error-highlight);
}
&:hover .type-icon {
opacity: 1;
}
.type-icon {
width: 1.5rem;
height: 1.5rem;
opacity: 0.6;
transition: opacity 0.2s;
margin: auto 1.5rem;
@include mobile-only {
width: 1.25rem;
height: 1.25rem;
margin: auto 1rem;
}
}
&__info {
min-width: 0;
padding: 0.85rem 0.85rem 0.85rem 0;
display: flex;
flex-direction: column;
justify-content: center;
@include mobile-only {
padding: 0.75rem 0.75rem 0.75rem 0;
}
}
&__title {
margin: 0 0 0.3rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
@include mobile-only {
font-size: 1rem;
}
}
&__description {
margin: 0;
font-size: 0.8rem;
color: var(--text-color-70);
line-height: 1.4;
@include mobile-only {
font-size: 0.75rem;
}
}
&__size {
color: var(--text-color-50);
font-family: monospace;
}
&__delete {
width: 70px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-error);
border: none;
cursor: pointer;
transition: all 0.2s;
@include mobile-only {
width: 60px;
}
svg {
width: 20px;
height: 20px;
fill: white;
transition: transform 0.2s;
@include mobile-only {
width: 18px;
height: 18px;
}
}
&:hover {
background: var(--color-error-highlight);
svg {
transform: scale(1.1);
}
}
&:active svg {
transform: scale(0.9);
}
}
}
.chevron-container {
width: 20px;
height: 20px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
// Simple crossfade transition
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,462 +0,0 @@
<template>
<div class="server-storage">
<div class="server-storage__intro">
<h2 class="server-storage__title">Server Storage</h2>
<p class="server-storage__description">
Data stored on our servers to sync across your devices and provide
personalized features.
</p>
</div>
<div class="server-sections">
<div
v-for="section in serverSections"
:key="section.type"
:class="`server-section server-section--${section.type}`"
>
<button
class="server-section__header"
@click="
expandedSections[section.type] = !expandedSections[section.type]
"
>
<div class="server-section__header-content">
<component :is="section.iconComponent" class="section-icon" />
<h3 class="server-section__title">{{ section.title }}</h3>
<span class="server-section__count">{{
section.items.length
}}</span>
<span class="server-section__size">{{ section.totalSize }}</span>
</div>
<svg
class="server-section__chevron"
:class="{
'server-section__chevron--expanded':
expandedSections[section.type]
}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<div
v-if="expandedSections[section.type]"
class="server-section__content"
>
<p class="server-section__description">{{ section.description }}</p>
<div class="server-items">
<div
v-for="item in section.items"
:key="item.key"
:class="`server-item server-item--${section.type}`"
>
<component :is="section.iconComponent" class="type-icon" />
<div class="server-item__info">
<h4 class="server-item__title">{{ item.key }}</h4>
<p class="server-item__description">
<span v-if="item.description">{{ item.description }} · </span>
<span class="server-item__size">{{ item.size }}</span>
<span v-if="item.lastSynced" class="server-item__synced">
· Last synced: {{ item.lastSynced }}</span
>
</p>
</div>
<button
class="server-item__delete"
@click="$emit('clear-item', item.key, section.type)"
:title="`Delete ${item.key}`"
>
<IconClose />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import IconClose from "@/icons/IconClose.vue";
import IconProfile from "@/icons/IconProfile.vue";
import IconSettings from "@/icons/IconSettings.vue";
import IconActivity from "@/icons/IconActivity.vue";
interface ServerItem {
key: string;
description: string;
size: string;
lastSynced?: string;
}
defineEmits<{
"clear-item": [key: string, type: string];
}>();
const expandedSections = ref<Record<string, boolean>>({
profile: false,
preferences: false,
activity: false
});
// Mock server data
const serverSections = computed(() => [
{
type: "profile",
title: "Profile Data",
iconComponent: IconProfile,
description:
"Your account information, settings, and preferences stored on our servers.",
items: [
{
key: "user_profile",
description: "User account details",
size: "2.4 KB",
lastSynced: "2 hours ago"
},
{
key: "avatar_image",
description: "Profile picture",
size: "145 KB",
lastSynced: "1 day ago"
},
{
key: "email_preferences",
description: "Notification settings",
size: "512 Bytes",
lastSynced: "3 days ago"
}
],
totalSize: "147.9 KB"
},
{
type: "preferences",
title: "Synced Preferences",
iconComponent: IconSettings,
description:
"Settings that sync across all your devices when you sign in.",
items: [
{
key: "theme_settings",
description: "Color theme and appearance",
size: "1.1 KB",
lastSynced: "5 hours ago"
},
{
key: "playback_settings",
description: "Video and audio preferences",
size: "856 Bytes",
lastSynced: "1 day ago"
},
{
key: "library_filters",
description: "Saved filters and sorting",
size: "2.3 KB",
lastSynced: "2 days ago"
}
],
totalSize: "4.3 KB"
},
{
type: "activity",
title: "Activity History",
iconComponent: IconActivity,
description:
"Your viewing history and watch progress stored on our servers.",
items: [
{
key: "watch_history",
description: "Recently watched items",
size: "12.5 KB",
lastSynced: "1 hour ago"
},
{
key: "watch_progress",
description: "Playback positions",
size: "8.2 KB",
lastSynced: "30 minutes ago"
},
{
key: "favorites",
description: "Starred and favorited content",
size: "3.7 KB",
lastSynced: "6 hours ago"
}
],
totalSize: "24.4 KB"
}
]);
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.server-storage {
&__intro {
margin-bottom: 2rem;
}
&__title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-color);
}
&__description {
margin: 0;
color: var(--text-color-70);
font-size: 0.95rem;
line-height: 1.6;
}
}
.server-sections {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.server-section {
border-radius: 0.5rem;
background: var(--background-ui);
overflow: hidden;
border: 2px solid transparent;
transition: border-color 0.2s ease;
&--profile {
border-color: rgba(59, 130, 246, 0.2);
.section-icon,
.type-icon {
fill: #3b82f6;
}
}
&--preferences {
border-color: rgba(16, 185, 129, 0.2);
.section-icon,
.type-icon {
fill: #10b981;
}
}
&--activity {
border-color: rgba(245, 158, 11, 0.2);
.section-icon,
.type-icon {
fill: #f59e0b;
}
}
&__header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: var(--background-40);
}
}
&__header-content {
display: flex;
align-items: center;
gap: 0.75rem;
.section-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
}
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background: var(--background-40);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-70);
}
&__size {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color-50);
font-family: monospace;
}
&__chevron {
width: 20px;
height: 20px;
stroke: var(--text-color-70);
transition: transform 0.2s ease;
&--expanded {
transform: rotate(180deg);
}
}
&__content {
border-top: 1px solid var(--background-40);
}
&__description {
margin: 0;
padding: 1rem 1.25rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-color-70);
background: var(--background-color);
font-style: italic;
}
}
.server-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem 1rem 1rem;
}
.server-item {
display: grid;
grid-template-columns: auto 1fr auto;
background: var(--background-color);
border-radius: 0.25rem;
overflow: hidden;
transition: all 0.2s ease;
border-left: 3px solid;
&--profile {
border-color: #3b82f6;
background: linear-gradient(
90deg,
rgba(59, 130, 246, 0.1),
var(--background-color)
);
}
&--preferences {
border-color: #10b981;
background: linear-gradient(
90deg,
rgba(16, 185, 129, 0.1),
var(--background-color)
);
}
&--activity {
border-color: #f59e0b;
background: linear-gradient(
90deg,
rgba(245, 158, 11, 0.1),
var(--background-color)
);
}
&:hover .server-item__delete {
background: var(--color-error-highlight);
}
&:hover .type-icon {
opacity: 1;
}
.type-icon {
width: 1.5rem;
height: 1.5rem;
opacity: 0.6;
transition: opacity 0.2s;
margin: auto 1.5rem;
@include mobile-only {
width: 1.25rem;
height: 1.25rem;
margin: auto 1rem;
}
}
&__info {
min-width: 0;
padding: 0.85rem 0.85rem 0.85rem 0;
display: flex;
flex-direction: column;
justify-content: center;
@include mobile-only {
padding: 0.75rem 0.75rem 0.75rem 0;
}
}
&__title {
margin: 0 0 0.3rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-color);
@include mobile-only {
font-size: 1rem;
}
}
&__description {
margin: 0;
font-size: 0.8rem;
color: var(--text-color-70);
line-height: 1.4;
@include mobile-only {
font-size: 0.75rem;
}
}
&__size {
color: var(--text-color-50);
font-family: monospace;
}
&__synced {
color: var(--text-color-50);
font-style: italic;
}
&__delete {
width: 70px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-error);
border: none;
cursor: pointer;
transition: all 0.2s;
@include mobile-only {
width: 60px;
}
svg {
width: 20px;
height: 20px;
fill: white;
transition: transform 0.2s;
@include mobile-only {
width: 18px;
height: 18px;
}
}
&:hover {
background: var(--color-error-highlight);
svg {
transform: scale(1.1);
}
}
&:active svg {
transform: scale(0.9);
}
}
}
</style>

View File

@@ -1,355 +0,0 @@
<template>
<div class="theme-preferences">
<div class="current-theme">
<div class="theme-display">
<div class="theme-icon" :data-theme="selectedTheme">
<div class="icon-inner"></div>
</div>
<div class="theme-info">
<span class="theme-label">Current Theme</span>
<h3 class="theme-name">{{ currentThemeName }}</h3>
</div>
</div>
</div>
<div class="theme-grid">
<button
v-for="theme in themes"
:key="theme.value"
:class="['theme-card', { active: selectedTheme === theme.value }]"
@click="selectTheme(theme.value)"
>
<div class="theme-card__preview" :data-theme="theme.value">
<div class="preview-circle"></div>
</div>
<span class="theme-card__name">{{ theme.label }}</span>
<div v-if="selectedTheme === theme.value" class="theme-card__badge">
Active
</div>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useTheme } from "@/composables/useTheme";
const themes = [
{ value: "auto", label: "Auto" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "ocean", label: "Ocean" },
{ value: "nordic", label: "Nordic" },
{ value: "halloween", label: "Halloween" }
] as const;
const { currentTheme, savedTheme, setTheme } = useTheme();
const selectedTheme = currentTheme;
const currentThemeName = computed(
() => themes.find(t => t.value === selectedTheme.value)?.label ?? "Auto"
);
function selectTheme(theme: string) {
setTheme(theme as any);
}
onMounted(() => {
selectedTheme.value = savedTheme.value;
});
</script>
<style lang="scss" scoped>
@import "scss/variables";
@import "scss/media-queries";
.current-theme {
margin-bottom: 2rem;
padding: 1rem 2rem;
background-color: var(--background-ui);
border-radius: 1rem;
border: 2px solid var(--background-40);
@include mobile-only {
margin-bottom: 1.5rem;
padding: 1.5rem;
border-radius: 0.75rem;
}
.theme-display {
display: flex;
align-items: center;
gap: 1.5rem;
@include mobile-only {
gap: 1.25rem;
}
}
.theme-icon {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
@include mobile-only {
width: 70px;
height: 70px;
}
.icon-inner {
width: 100%;
height: 100%;
border-radius: 50%;
border: 3px solid;
}
&[data-theme="light"] .icon-inner {
background: linear-gradient(135deg, #f8f8f8, #e8e8e8);
border-color: #01d277;
}
&[data-theme="dark"] .icon-inner {
background: linear-gradient(135deg, #1a1a1a, #0a0a0a);
border-color: #01d277;
}
&[data-theme="ocean"] .icon-inner {
background: linear-gradient(135deg, #0f2027, #2c5364);
border-color: #00d4ff;
}
&[data-theme="nordic"] .icon-inner {
background: linear-gradient(135deg, #f5f0e8, #d8cdb9);
border-color: #3d6e4e;
}
&[data-theme="halloween"] .icon-inner {
background: linear-gradient(135deg, #1a0e2e, #2d1b3d);
border-color: #ff6600;
}
&[data-theme="auto"] .icon-inner {
background: conic-gradient(#f8f8f8 0deg 180deg, #1a1a1a 180deg);
border-color: #01d277;
}
}
.theme-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
span {
font-size: 0.85rem;
color: $text-color-70;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
@include mobile-only {
font-size: 0.75rem;
}
}
h3 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
line-height: 1;
@include mobile-only {
font-size: 1.4rem;
}
}
}
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
@include mobile-only {
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.theme-card {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 1rem;
background-color: var(--background-ui);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
text-align: center;
@include mobile-only {
padding: 0.85rem;
border-radius: 0.5rem;
&:hover {
transform: none;
}
&:active {
transform: scale(0.97);
}
}
&:hover {
border-color: var(--highlight-color);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
&.active {
border-color: var(--highlight-color);
background-color: var(--background-40);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&__preview {
width: 100%;
height: 120px;
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 0.75rem;
position: relative;
border: 1px solid var(--background-40);
@include mobile-only {
height: 100px;
margin-bottom: 0.6rem;
}
.preview-circle {
position: absolute;
bottom: 8px;
left: 8px;
width: 30px;
height: 30px;
border-radius: 50%;
}
&::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
height: 20px;
border-radius: 4px;
border: 1px solid;
}
}
&__preview[data-theme="light"] {
background: #f8f8f8;
.preview-circle {
background: #01d277;
}
&::before {
background: #fff;
border-color: rgba(8, 28, 36, 0.1);
}
}
&__preview[data-theme="dark"] {
background: #111;
.preview-circle {
background: #01d277;
}
&::before {
background: #060708;
border-color: rgba(255, 255, 255, 0.1);
}
}
&__preview[data-theme="ocean"] {
background: #0f2027;
.preview-circle {
background: #00d4ff;
}
&::before {
background: #203a43;
border-color: rgba(0, 212, 255, 0.2);
}
}
&__preview[data-theme="nordic"] {
background: #f5f0e8;
.preview-circle {
background: #3d6e4e;
}
&::before {
background: #fffef9;
border-color: rgba(61, 110, 78, 0.2);
}
}
&__preview[data-theme="halloween"] {
background: #1a0e2e;
.preview-circle {
background: #ff6600;
}
&::before {
background: #2d1b3d;
border-color: rgba(255, 102, 0, 0.2);
}
}
&__preview[data-theme="auto"] {
border-color: black;
background: linear-gradient(
135deg,
#f8f8f8 0%,
#f8f8f8 50%,
#111 50%,
#111 100%
);
.preview-circle {
left: auto;
right: 8px;
background: #01d277;
}
&::before {
right: auto;
width: calc(50% - 10px);
background: #fff;
border-color: rgba(8, 28, 36, 0.1);
}
}
&__name {
font-size: 0.9rem;
font-weight: 600;
line-height: 1;
color: var(--text-color);
@include mobile-only {
font-size: 0.85rem;
}
}
&__badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.5rem;
background-color: var(--highlight-color);
color: white;
border-radius: 1rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
@include mobile-only {
top: 0.4rem;
right: 0.4rem;
padding: 0.2rem 0.4rem;
font-size: 0.6rem;
}
}
}
}
</style>

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