@ -1,2 +0,0 @@ | |||
# The base URL the site is hosted on | |||
BASE_URL = "https://autoplex.dlii.tech" |
@ -1,3 +0,0 @@ | |||
# Autoplex Web UI | |||
The web UI for the Autoplex service |
@ -1,13 +0,0 @@ | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset="UTF-8" /> | |||
<link rel="icon" href="/favicon.ico" /> | |||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |||
<title>Autoplex</title> | |||
</head> | |||
<body class="bg-blue-50"> | |||
<div id="app" class="contents"></div> | |||
<script type="module" src="/src/app/index.ts"></script> | |||
</body> | |||
</html> |
@ -1,41 +0,0 @@ | |||
{ | |||
"name": "@autoplex-service/autoplex-webui", | |||
"version": "0.0.0", | |||
"keywords": [], | |||
"author": "David Ludwig", | |||
"license": "ISC", | |||
"main": "./dist/server/index.js", | |||
"scripts": { | |||
"clean": "rimraf ./dist", | |||
"build": "yarn run build:backend && yarn run build:frontend", | |||
"build:backend": "tsc -p ./tsconfig.server.json", | |||
"build:frontend": "vue-tsc --noEmit -p ./tsconfig.vite.json && vite build", | |||
"start": "NODE_ENV=production node .", | |||
"start:dev": "vite" | |||
}, | |||
"dependencies": { | |||
"@fortawesome/fontawesome-free": "^5.15.3", | |||
"fastify": "^3.14.1", | |||
"fastify-static": "^4.0.1", | |||
"jwt-decode": "^3.1.2", | |||
"validate.js": "^0.13.1", | |||
"vue": "^3.0.5", | |||
"vue-router": "^4.0.6", | |||
"vuedraggable": "^4.0.1", | |||
"vuex": "^4.0.0", | |||
"websocket": "^1.0.33" | |||
}, | |||
"devDependencies": { | |||
"@types/node": "^15.0.1", | |||
"@vitejs/plugin-vue": "^1.2.1", | |||
"@vue/compiler-sfc": "^3.0.5", | |||
"autoprefixer": "^10.2.5", | |||
"postcss": "^8.2.9", | |||
"rimraf": "^3.0.2", | |||
"tailwindcss": "^2.1.1", | |||
"ts-node": "^9.1.1", | |||
"typescript": "^4.1.3", | |||
"vite": "^2.1.5", | |||
"vue-tsc": "^0.0.15" | |||
} | |||
} |
@ -1,6 +0,0 @@ | |||
module.exports = { | |||
plugins: { | |||
tailwindcss: {}, | |||
autoprefixer: {} | |||
}, | |||
} |
@ -1,27 +0,0 @@ | |||
<template> | |||
<div class="h-full flex flex-col lg:flex-row"> | |||
<side-nav v-if="!$route.meta['disableNavBar']"/> | |||
<div class="h-full p-4 flex flex-col flex-grow overflow-y-auto"> | |||
<router-view/> | |||
</div> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from 'vue'; | |||
import { Action, useStore } from "./store"; | |||
import AppModal from "./components/AppModals.vue"; | |||
import SideNav from "./components/SideNav.vue"; | |||
export default defineComponent({ | |||
name: "App", | |||
components: { | |||
AppModal, | |||
SideNav | |||
}, | |||
setup() { | |||
const store = useStore(); | |||
store.dispatch(Action.AuthLoad, undefined); | |||
} | |||
}); | |||
</script> |
@ -1 +0,0 @@ | |||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 320.03 103.61"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:url(#radial-gradient);}.cls-3{fill:#e5a00d;}</style><radialGradient id="radial-gradient" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset="0.17" stop-color="#f9be03"/><stop offset="0.51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><title>plex-logo</title><polygon id="X" class="cls-1" points="320.03 -0.09 289.96 -0.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79 320.03 -0.09"/><g id="chevron"><polygon class="cls-2" points="226.7 -0.09 256.78 -0.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -0.09"/><polygon class="cls-3" points="226.7 -0.09 256.78 -0.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -0.09"/></g><path id="E" class="cls-1" d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z"/><path id="L" class="cls-1" d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z"/><path id="P" class="cls-1" d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z"/></svg> |
@ -1,46 +0,0 @@ | |||
<template> | |||
<div class="modal min-h-screen" v-if="visible"> | |||
<component :is="component"></component> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import { Mutation } from "../store"; | |||
import { defineComponent } from "vue"; | |||
import { mapMutations, mapState } from "vuex"; | |||
import { modals } from "./modals"; | |||
export default defineComponent({ | |||
name: "AppModal", | |||
components: modals, | |||
computed: { | |||
...mapState({ | |||
component: "modalName", | |||
visible: "modalVisible" | |||
}) | |||
}, | |||
methods: { | |||
modalClicked() { | |||
console.log("Modal clicked"); | |||
}, | |||
// ...mapMutations({ | |||
// hide: Mutation.ModalHide | |||
// }) | |||
}, | |||
mounted() { | |||
document.addEventListener("keydown", (event: KeyboardEvent) => { | |||
if (event.key != "Escape") { | |||
return; | |||
} | |||
// this.hide(); | |||
}); | |||
} | |||
}); | |||
</script> | |||
<style lang="postcss"> | |||
.modal { | |||
@apply fixed inset-0 flex flex-col; | |||
background: rgba(0, 0, 0, 0.5); | |||
} | |||
</style> |
@ -1,100 +0,0 @@ | |||
<template> | |||
<label class="checkbox-label relative flex items-center"> | |||
<input class="hidden" type="checkbox" v-model="isChecked" :disabled="disabled"> | |||
<span></span> | |||
<span v-if="label" class="ml-2">{{label}}</span> | |||
</label> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
export default defineComponent({ | |||
data() { | |||
return { | |||
isChecked: this.modelValue | |||
} | |||
}, | |||
props: { | |||
disabled: { | |||
type: Boolean, | |||
default: false | |||
}, | |||
label: { | |||
type: String, | |||
required: true | |||
}, | |||
modelValue: { | |||
type: Boolean, | |||
default: false | |||
} | |||
}, | |||
watch: { | |||
isChecked(newState) { | |||
this.$emit("update:modelValue", newState); | |||
this.$emit("onChange", newState); | |||
} | |||
} | |||
}); | |||
</script> | |||
<style> | |||
.checkbox-label input[type="checkbox"]:focus-visible + span { | |||
@apply ring ring-offset-2 ring-indigo-500; | |||
} | |||
.checkbox-label input[type="checkbox"] + span { | |||
@apply inline-block w-4 h-4 bg-white border rounded-sm border-gray-300; | |||
} | |||
.checkbox-label input[type="checkbox"]:checked + span { | |||
@apply bg-indigo-500 border-indigo-500; | |||
} | |||
.checkbox-label input[type="checkbox"] + span::before { | |||
content: ''; | |||
position: absolute; | |||
border-color: white; | |||
border-width: 0; | |||
/* transform: translateY(0.3rem) rotate(45deg); */ | |||
} | |||
.checkbox-label input[type="checkbox"]:checked + span::before { | |||
border-width: 0 0px 2px 2px; | |||
width: .5rem; | |||
height: .25rem; | |||
transform: translateX(calc(50% - 1px)) translateY(calc(0.3rem - 1px)) rotate(-45deg); | |||
} | |||
@keyframes shrink-bounce{ | |||
0%{ | |||
transform: scale(1); | |||
} | |||
33%{ | |||
transform: scale(.85); | |||
} | |||
100%{ | |||
transform: scale(1); | |||
} | |||
} | |||
@keyframes checkbox-check{ | |||
0%{ | |||
width: 0; | |||
height: 0; | |||
border-color: #212121; | |||
transform: translate3d(0,0,0) rotate(45deg); | |||
} | |||
33%{ | |||
width: .2em; | |||
height: 0; | |||
transform: translate3d(0,0,0) rotate(45deg); | |||
} | |||
100%{ | |||
width: .2em; | |||
height: .5em; | |||
border-color: #212121; | |||
transform: translate3d(0,-.5em,0) rotate(45deg); | |||
} | |||
} | |||
</style> |
@ -1,51 +0,0 @@ | |||
<template> | |||
<ul class="w-full grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 lg:gap-8"> | |||
<li class="flex flex-row bg-white rounded-lg overflow-hidden shadow-md lg:flex-col transform hover:scale-105 transition-transform cursor-pointer" v-for="(movie, index) in movies" | |||
@click="$emit('onClickMovie', movie, index)"> | |||
<movie-poster class="w-3/12 lg:w-full flex-shrink-0" :src="movie.posterPath ?? undefined" size="w185"/> | |||
<div class="relative box-border p-4 flex flex-col justify-center w-full lg:h-full text-md xs:text-lg sm:text-xl md:text-base xl:text-sm" style="box-shadow: 0px -10px 10px rgba(0, 0, 0, 0.10);"> | |||
<div class="flex-grow flex flex-col justify-center lg:justify-start"> | |||
<h3 class="font-medium">{{ movie.title }}</h3> | |||
<span v-if="movie.releaseDate" class="opacity-50">{{ movie.releaseDate.slice(0, 4) }}</span> | |||
</div> | |||
<div v-if="movie.plexLink" title="Added to Plex" class="flex"> | |||
<div class="inline-block bg-ui-plexGray rounded-full w-1/6 lg:w-1/3"> | |||
<img src="../assets/plex_logo.svg" alt="Added to Plex" style="margin: 15% 25%;"/> | |||
</div> | |||
</div> | |||
<!-- <div class="flex flex-row items-end space-x-2"> | |||
<div class="w-full rounded-full bg-gray-200 overflow-hidden flex-grow-0"> | |||
<div class="w-1/2 bg-red-500 h-1"></div> | |||
</div> | |||
<div class="relative h-1 flex justify-center items-center text-sm"> | |||
<span class="h-0 overflow-hidden">100%</span> | |||
<span class="absolute left-0 text-center w-full opacity-75">{{ 50 }}%</span> | |||
</div> | |||
</div> --> | |||
</div> | |||
</li> | |||
</ul> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
import { IApiMovie } from "../../common/api_schema"; | |||
import MoviePoster from "./MoviePoster.vue"; | |||
export default defineComponent({ | |||
components: { | |||
MoviePoster | |||
}, | |||
methods: { | |||
onModalClosed() { | |||
let parentPath = this.$route.path.split('/').slice(0, -1).join('/'); | |||
this.$router.push({ path: parentPath, query: this.$route.query }); | |||
} | |||
}, | |||
props: { | |||
movies: { | |||
default: <IApiMovie[]>[] | |||
} | |||
} | |||
}); | |||
</script> |
@ -1,64 +0,0 @@ | |||
<template> | |||
<div class="poster"> | |||
<div class="poster-padding"></div> | |||
<img v-if="src" class="poster-content w-full h-full object-cover" loading="lazy" ref="poster" | |||
:src="`/api/tmdb/image/${size}${src}`" @load="onPosterLoad"> | |||
<div v-else class="poster-content bg-gray-300 text-gray-500"> | |||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg> | |||
</div> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import ProgressRing from './ProgressRing.vue' | |||
import { defineComponent } from "vue"; | |||
export default defineComponent({ | |||
components: { | |||
ProgressRing, | |||
}, | |||
data() { | |||
return { | |||
isLoaded: false | |||
} | |||
}, | |||
methods: { | |||
onPosterLoad() { | |||
this.isLoaded = true; | |||
this.$emit("onLoad", this.$refs["poster"]); | |||
} | |||
}, | |||
props: { | |||
size: { | |||
type: String, | |||
default: "w342" | |||
}, | |||
src: String | |||
} | |||
}); | |||
</script> | |||
<style lang="css"> | |||
.poster { | |||
@apply relative overflow-hidden; | |||
} | |||
.poster > .poster-padding { | |||
padding-top: 143.885%; | |||
} | |||
.poster > .poster-decal { | |||
@apply absolute z-10 top-0 w-full shadow-sm; | |||
padding: 4%; | |||
background: rgb(31, 35, 38); | |||
transform: rotate(-45deg) translateX(-28%) translateY(-100%); | |||
} | |||
.poster > .poster-decal > img { | |||
@apply w-1/3 mx-auto; | |||
} | |||
.poster > .poster-content { | |||
@apply absolute inset-0 flex justify-center items-center z-0 shadow-sm; | |||
} | |||
.poster > .poster-content > svg { | |||
width: 35%; | |||
} | |||
</style> |
@ -1,97 +0,0 @@ | |||
<template> | |||
<div class="box-border relative text-white radial-progress"> | |||
<svg class="transform -rotate-90" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" style="height: 100%; width: 100%"> | |||
<circle | |||
class="radial-progress-ring__body" | |||
:r="50" | |||
:cx="50" | |||
:cy="50"/> | |||
<circle | |||
class="radial-progress-ring__progress" | |||
:stroke-width="ringWidth" | |||
:r="50 - ringWidth/2" | |||
:cx="50" | |||
:cy="50" | |||
ref="ring" | |||
/> | |||
<text text-anchor="middle" alignment-baseline="central" x="50" y="50" :font-size="fontSize" transform="rotate(90) translate(0,-100)">{{ progressFormatted }}</text> | |||
</svg> | |||
<!-- <div class="antialiased flex absolute inset-0 items-center justify-center text-xs font-light rounded-full text-white">{{ progressFormatted }}%</div> --> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
export enum RadialProgressState { | |||
Downloading, | |||
Paused, | |||
Halted, | |||
Error | |||
} | |||
export default defineComponent({ | |||
computed: { | |||
circumference(): number { | |||
return 2*Math.PI*(50 - this.ringWidth/2); | |||
}, | |||
progressFormatted(): number { | |||
return Math.floor(100*(<number>this.progress)); | |||
} | |||
}, | |||
methods: { | |||
updateProgressBar() { | |||
(<HTMLElement>this.$refs.ring).style.strokeDasharray = `${this.circumference} ${this.circumference}`; | |||
(<HTMLElement>this.$refs.ring).style.strokeDashoffset = `${this.circumference - this.circumference*this.progress}`; | |||
} | |||
}, | |||
mounted() { | |||
this.updateProgressBar(); | |||
}, | |||
props: { | |||
fontSize: { | |||
type: Number, | |||
required: true | |||
}, | |||
progress: { | |||
type: Number, | |||
required: true | |||
}, | |||
ringWidth: { | |||
type: Number, | |||
default: 1 | |||
} | |||
}, | |||
watch: { | |||
progress() { | |||
this.updateProgressBar(); | |||
} | |||
} | |||
}); | |||
</script> | |||
<style lang="css"> | |||
.radial-progress text { | |||
fill: white; | |||
} | |||
.radial-progress-ring__body { | |||
fill: #1f2937; | |||
} | |||
.radial-progress-ring__progress { | |||
fill: transparent; | |||
stroke: #34D399; | |||
transition: stroke-dashoffset 0.25s; | |||
} | |||
.bg-modal { | |||
background: rgba(0, 0, 0, 0.35); | |||
opacity: 0.0; | |||
transition: opacity 0.25s; | |||
} | |||
.show-state { | |||
opacity: 1.0; | |||
} | |||
.radial-progress:hover .bg-modal { | |||
opacity: 1.0; | |||
} | |||
</style> |
@ -1,126 +0,0 @@ | |||
<template> | |||
<transition name="fade"> | |||
<div v-if="isOpen" class="modal md:hidden p-8 overflow-auto items-center z-30" @click.self="close"></div> | |||
</transition> | |||
<div class="contents" ref="root"> | |||
<div class="lg:hidden bg-white shadow-md items-center flex z-40"> | |||
<button class="nav-toggle-button" :class="{ 'active': isOpen }" | |||
@click="isOpen = !isOpen"> | |||
<i class="fas fa-bars"></i> | |||
</button> | |||
</div> | |||
<nav class="fixed lg:relative inset-y-0 w-72 lg:w-64 xl:w-72 flex flex-col flex-shrink-0 bg-white shadow-lg z-50" :class="{'active': isOpen}"> | |||
<div class="brand">AUTOPLEX</div> | |||
<div class="mt-4 text-center"> | |||
<div class="user"><i class="fas opacity-40" :class="{'fa-user': !isAdmin, 'fa-user-tie': isAdmin}"></i></div> | |||
<div class="font-black text-lg opacity-80">{{ userName }}</div> | |||
<div class="text-xs opacity-60">{{ isAdmin ? "Admin" : "Plex Member" }}</div> | |||
</div> | |||
<ul class="mt-4 text-gray-800 links"> | |||
<li v-if="isAdmin"> | |||
<router-link :to="{ name: 'Admin' }"> | |||
<span><i class="fas fa-cog"></i></span> | |||
<div>Admin</div> | |||
</router-link> | |||
</li> | |||
<li> | |||
<router-link :to="{ name: 'Dashboard' }"> | |||
<span><i class="fas fa-chart-pie"></i></span> | |||
<div>Dashboard</div> | |||
</router-link> | |||
</li> | |||
<li> | |||
<router-link :to="{ name: 'Search' }"> | |||
<span><i class="fas fa-search"></i></span> | |||
<div>Search</div> | |||
</router-link> | |||
</li> | |||
</ul> | |||
<div class="mt-auto p-4"> | |||
<router-link class="block p-2 text-center rounded-full shadow-md text-white bg-indigo-500" | |||
:to="{ name: 'Logout' }"> | |||
Logout | |||
</router-link> | |||
</div> | |||
</nav> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
import { mapGetters } from "vuex"; | |||
export default defineComponent({ | |||
computed: { | |||
...mapGetters([ | |||
"userName", | |||
"isAdmin" | |||
]) | |||
}, | |||
data() { | |||
return { | |||
isOpen: false | |||
} | |||
}, | |||
mounted() { | |||
document.addEventListener("click", (event: any) => { | |||
if (this.isOpen) { | |||
if (-1 == event.path.indexOf(this.$refs["root"])) { | |||
this.isOpen = false; | |||
} | |||
} | |||
}); | |||
}, | |||
watch: { | |||
$route(to, from) { | |||
if (this.isOpen) { | |||
this.isOpen = false; | |||
} | |||
} | |||
} | |||
}); | |||
</script> | |||
<style lang="postcss"> | |||
.nav-toggle-button { | |||
@apply w-14 h-14 flex items-center justify-center text-2xl focus:ring-0 focus:outline-none ring-indigo-500; | |||
transition: margin 0.15s cubic-bezier(0.4, 0.0, 1, 1); | |||
} | |||
.nav-toggle-button.active { | |||
@apply ml-72; | |||
transition: margin 0.25s cubic-bezier(0.0, 0.0, 0.2, 1); | |||
} | |||
nav { | |||
transform: translateX(-100%); | |||
transition: transform 0.15s cubic-bezier(0.4, 0.0, 1, 1); | |||
} | |||
@media all and (min-width: 1024px) { | |||
nav { | |||
transform: none !important; | |||
} | |||
} | |||
nav.active { | |||
transform: none; | |||
transition: transform 0.25s cubic-bezier(0.0, 0.0, 0.2, 1); | |||
} | |||
nav > .brand { | |||
@apply mt-4 h-12 flex justify-center items-center text-4xl font-thin; | |||
} | |||
nav .user { | |||
@apply w-20 h-20 mx-auto rounded-full flex justify-center items-end bg-gray-200 text-6xl overflow-hidden shadow-sm; | |||
} | |||
nav .user > i { | |||
line-height: 3.7rem; | |||
} | |||
nav > .links a { | |||
@apply py-3 px-8 flex space-x-4 hover:bg-gray-100; | |||
} | |||
nav > .links .router-link-active { | |||
@apply bg-indigo-500 text-white shadow-md hover:bg-indigo-500 cursor-default; | |||
} | |||
nav > .links span { | |||
@apply w-5 | |||
} | |||
</style> |
@ -1,113 +0,0 @@ | |||
<template> | |||
<div> | |||
<label class="block text-sm mb-1 text-gray-700" :class="{ 'opacity-50': disabled, 'text-red-600': error }"> | |||
{{ label }} | |||
</label> | |||
<div class="block relative"> | |||
<input :type="type" :placeholder="placeholder" v-model="value" ref="textbox" :disabled="disabled" | |||
class="w-full outline-none p-2 rounded-lg bg-gray-100 border border-gray-100 text-gray-800 placeholder-gray-400 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-gray-50 disabled:border-gray-400" | |||
:class="{ 'pr-10': (isValid || error), 'border-red-600 text-red-600': error }" | |||
@blur="onBlur" @change="onChange" @input="setErrorMessage('')"> | |||
<span class="w-10 absolute flex items-center top-0 bottom-0 right-0 justify-center" :class="{ 'hidden': !error && !isValid }"> | |||
<i v-if="error" class="fas fa-exclamation-circle align-middle text-red-600"></i> | |||
<i v-else-if="isValid" class="fas fa-check-circle align-middle text-green-500"></i> | |||
</span> | |||
</div> | |||
<div class="w-full error-message text-xs text-red-600 text-center" ref="error">{{ error }}</div> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
import { validateValue } from "../util"; | |||
export default defineComponent({ | |||
emits: ["onBlur", "onChange", "update:modelValue", "update:errorValue"], | |||
data() { | |||
return { | |||
error: "", | |||
isValid: <boolean|undefined> this.valid, | |||
value: this.modelValue | |||
} | |||
}, | |||
methods: { | |||
/** | |||
* Invoked when the input has lost focus | |||
*/ | |||
onBlur(event: Event) { | |||
this.$emit("onBlur", event); | |||
this.validate(); | |||
}, | |||
/** | |||
* Invoked when the value of the input has changed | |||
*/ | |||
onChange(event: Event) { | |||
this.$emit("update:modelValue", this.value); | |||
this.$emit("onChange", event); | |||
}, | |||
/** | |||
* Set the displayed error message | |||
*/ | |||
setErrorMessage(value: string) { | |||
this.error = value; | |||
this.$emit("update:errorValue", value); | |||
if (value) { | |||
this.isValid = false; | |||
} | |||
}, | |||
/** | |||
* Validate the current input | |||
*/ | |||
async validate() { | |||
this.isValid = false; | |||
if (this.validator !== undefined) { | |||
if (typeof this.validator === "function") { | |||
this.error = (await this.validator(this.value)) ?? ""; | |||
} else { | |||
this.error = validateValue(this.value, this.validator); | |||
} | |||
if (!this.error) { | |||
this.isValid = true; | |||
} | |||
} | |||
} | |||
}, | |||
mounted() { | |||
if (this.value && this.validator) { | |||
this.validate(); | |||
} | |||
}, | |||
props: { | |||
disabled: { | |||
type: Boolean, | |||
default: false | |||
}, | |||
errorValue: { | |||
type: String, | |||
default: "" | |||
}, | |||
label: String, | |||
modelValue: { | |||
type: String, | |||
default: "" | |||
}, | |||
placeholder: { | |||
type: String, | |||
default: "" | |||
}, | |||
required: [Boolean, String], | |||
valid: { | |||
type: Boolean, | |||
default: undefined | |||
}, | |||
type: { | |||
type: String, | |||
default: "text" | |||
}, | |||
validator: [Function, Object] | |||
} | |||
}); | |||
</script> |
@ -1,220 +0,0 @@ | |||
<template> | |||
<transition appear name="fade"> | |||
<div v-if="tmdbId !== undefined" class="modal p-8 overflow-auto items-center z-50" @click.self="close"> | |||
<transition name="slide" appear | |||
@before-enter="lockScroll(true)" @enter-cancelled="lockScroll(false)" | |||
@leave="lockScroll(false)" @leave-cancelled="lockScroll(true)"> | |||
<div v-if="movie !== undefined" class="relative w-full max-w-6xl bg-white md:bg-none overflow-hidden rounded-xl flex-col flex-shrink-0 shadow-xl"> | |||
<div class="bg-center bg-cover bg-no-repeat" :class="{ 'text-white': isDark }" | |||
:style="movie.backdropPath ? `background: url('/api/tmdb/image/w1280${movie.backdropPath}')` : ''"> | |||
<div class="movie-modal-content z-10" :style="backdropOverlayStyle"> | |||
<div class="flex p-4 items-center"> | |||
<movie-poster v-if="movie.posterPath" :src="movie.posterPath" | |||
class="w-3/12 flex-shrink-0 rounded-xl shadow-md md:shadow-xl" @onLoad="onPosterLoad"/> | |||
<div class="p-4 flex flex-col justify-center"> | |||
<h2 class="text-lg md:text-4xl"> | |||
<span class="font-bold">{{ movie.title }}</span> | |||
<span class="opacity-70" v-if="releaseYear"> ({{ releaseYear }})</span> | |||
</h2> | |||
<div class="flex font-light dot-separated"> | |||
<span v-if="releaseDate">{{ releaseDate }}</span> | |||
<span v-if="runtime">{{ runtime }}</span> | |||
</div> | |||
<div v-if="movie?.overview" class="mt-4 hidden md:block"> | |||
<p class="">{{movie?.overview}}</p> | |||
</div> | |||
<div class="mt-4 hidden md:block"> | |||
<a class="inline-block py-2 px-6 rounded-full shadow-md hover:shadow-lg focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }" | |||
v-if="movie.plexLink !== null" :href="movie.plexLink" target="_blank">Watch Now</a> | |||
<button class="inline-block py-2 px-4 rounded-full shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }" | |||
v-else-if="movie.ticketId !== null">View Request Status</button> | |||
<button v-else class="inline-block py-2 w-36 rounded-full shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 transition-transform transform hover:scale-105" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }" | |||
:disabled="isRequesting" @click="request">{{isRequesting ? "Requesting..." : "Request"}}</button> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
</div> | |||
<div class="p-4 space-y-4 md:hidden z-20" style="box-shadow: 0px -10px 10px rgba(0, 0, 0, 0.10);" > | |||
<div v-if="movie?.overview"> | |||
<h3 class="font-bold mb-2">Overview</h3> | |||
<p>{{ movie.overview }}</p> | |||
</div> | |||
<div class="text-center"> | |||
<a class="inline-block py-2 px-8 rounded-full shadow-sm bg-red-500 hover:bg-black transition-colors text-white focus:outline-none ring-0" | |||
v-if="movie.plexLink !== null" :href="movie.plexLink">Watch Now</a> | |||
<button class="inline-block py-2 px-4 rounded-full bg-red-500 text-white disabled:opacity-50" | |||
v-else-if="movie.ticketId !== null">View Request Status</button> | |||
<button v-else class="inline-block py-2 px-8 rounded-full shadow-sm bg-red-500 hover:bg-black transition-colors text-white disabled:opacity-50 disabled:cursor-default focus:outline-none ring-0 disabled:bg-black" | |||
:disabled="isRequesting" @click="request">{{isRequesting ? "Requesting..." : "Request"}}</button> | |||
</div> | |||
</div> | |||
</div> | |||
</transition> | |||
</div> | |||
</transition> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
import { IApiDataResponse, IApiMovieDetails } from "@common/api_schema"; | |||
import { authFetch } from "../../routes"; | |||
import { getAverageRgb } from "../../util"; | |||
import { useStore, Mutation, Action } from "../../store"; | |||
import MoviePoster from "../MoviePoster.vue"; | |||
export default defineComponent({ | |||
components: { | |||
MoviePoster | |||
}, | |||
computed: { | |||
backdropOverlayStyle(): string { | |||
let { r, g, b } = this.rgb; | |||
return `background-image: linear-gradient(to right, rgba(${r}, ${g}, ${b}, 1.0), rgba(${r}, ${g}, ${b}, 0.84)`; | |||
}, | |||
isDark(): boolean { | |||
let { r, g, b } = this.rgb; | |||
return (r*0.299 + g*0.587 + b*0.114) <= 186; | |||
// r /= 255; | |||
// g /= 255; | |||
// b /= 255; | |||
// r = r <= 0.03928 ? r/12.92 : Math.pow((r+0.055)/1.055, 2.4); | |||
// g = g <= 0.03928 ? r/12.92 : Math.pow((r+0.055)/1.055, 2.4); | |||
// b = b <= 0.03928 ? r/12.92 : Math.pow((r+0.055)/1.055, 2.4); | |||
// let L = 0.2126*r + 0.7152*g + 0.0722*b; | |||
// return L <= 0.179; | |||
}, | |||
releaseYear(): string { | |||
if (!this.movie || !this.movie.releaseDate) { | |||
return ""; | |||
} | |||
return this.movie.releaseDate.slice(0, 4); | |||
}, | |||
releaseDate(): string { | |||
if (!this.movie || !this.movie.releaseDate) { | |||
return ""; | |||
} | |||
let [year, month, day] = this.movie.releaseDate.split('-'); | |||
return `${month}/${day}/${year}`; | |||
}, | |||
runtime(): string { | |||
if (!this.movie || !this.movie.runtime) { | |||
return ""; | |||
} | |||
let hours = Math.floor(this.movie.runtime / 60); | |||
let minutes = Math.floor(this.movie.runtime % 60); | |||
return (hours > 0 ? `${hours}h ` : "") + `${minutes}m`; | |||
} | |||
}, | |||
data() { | |||
return { | |||
rgb: {r: 0, g: 0, b: 0}, | |||
movie: <IApiMovieDetails|undefined>undefined, | |||
isPosterLoaded: false, | |||
isRequesting: false | |||
} | |||
}, | |||
methods: { | |||
lockScroll(lock: boolean) { | |||
useStore().commit(Mutation.LockScroll, lock); | |||
}, | |||
close() { | |||
this.movie = undefined; | |||
this.$emit("onClose"); | |||
}, | |||
onKeyPress(event: KeyboardEvent) { | |||
if (event.key != "Escape") { | |||
return; | |||
} | |||
this.close(); | |||
}, | |||
onPosterLoad(img: HTMLImageElement) { | |||
this.isPosterLoaded = true; | |||
this.computeBackdropColor(img); | |||
}, | |||
computeBackdropColor(img: HTMLImageElement) { | |||
// This is being weird and this is the only way I can fix it... | |||
let rgb: {[k: string]: number} = {r: 0, g: 0, b: 0}; | |||
try { | |||
rgb = getAverageRgb(img); | |||
} catch(e) { | |||
return { r: 0, g: 0, b: 0 }; | |||
} | |||
// Adjust contrast | |||
let c = 60; | |||
let f = 259*(c + 255) / (255*(259 - c)); | |||
for (let k in rgb) { | |||
rgb[k] = Math.min(Math.max(f*(rgb[k] - 128) + 128, 0), 255); | |||
} | |||
// Adjust brightness | |||
for (let k in rgb) { | |||
rgb[k] *= 0.90; | |||
} | |||
this.rgb = <any>rgb; | |||
}, | |||
async fetchMovieDetails() { | |||
if (this.tmdbId === undefined) { | |||
return; | |||
} | |||
let response = await (authFetch(`/api/movie/details/${this.tmdbId}`)); | |||
if (response.status != 200) { | |||
this.close(); | |||
return; | |||
} | |||
let movie = <IApiDataResponse<IApiMovieDetails>> await response.json(); | |||
this.movie = movie.data; | |||
}, | |||
async request() { | |||
if (this.isRequesting || this.movie == null || this.movie.tmdbId == null) { | |||
return; | |||
} | |||
this.isRequesting = true; | |||
let response = await this.$store.dispatch(Action.RequestMovie, this.movie.tmdbId); | |||
this.isRequesting = false; | |||
if (response.status == "Forbidden") { | |||
console.log("Failed to add movie: quota has been met"); | |||
return; | |||
} | |||
console.log(response); | |||
this.movie.ticketId = response.data.ticketId; | |||
} | |||
}, | |||
mounted() { | |||
document.addEventListener("keydown", this.onKeyPress); | |||
this.fetchMovieDetails(); | |||
}, | |||
beforeUnmount() { | |||
document.removeEventListener("keydown", this.onKeyPress); | |||
}, | |||
props: { | |||
tmdbId: { | |||
type: [Number, String], | |||
required: false | |||
} | |||
}, | |||
watch: { | |||
tmdbId(newId: number|string|undefined, oldId: number|string|undefined) { | |||
this.fetchMovieDetails(); | |||
} | |||
} | |||
}); | |||
</script> | |||
<style lang="postcss"> | |||
.dot-separated > *:not(:first-child)::before { | |||
@apply px-2; | |||
content: '\2022' | |||
} | |||
.modal button { | |||
@apply ring-0; | |||
} | |||
.modal button:focus-visible { | |||
@apply ring-2 ring-indigo-500 ring-offset-2; | |||
} | |||
</style> |
@ -1,22 +0,0 @@ | |||
/** | |||
* Modal names to export | |||
*/ | |||
const enum Modal { | |||
} | |||
/** | |||
* Modal export type | |||
*/ | |||
type ModalTypes = { [K in keyof typeof Modal]: any } | |||
/** | |||
* Modals to export | |||
*/ | |||
export const modals: ModalTypes = { | |||
} | |||
/** | |||
* Export the list of modal names | |||
*/ | |||
export default Modal; |
@ -1,20 +0,0 @@ | |||
import { createApp } from 'vue' | |||
import router from "./routes"; | |||
import store from "./store"; | |||
import App from './App.vue' | |||
import "./styles/index.css"; | |||
let app = createApp(App); | |||
app.use(router); | |||
app.use(store); | |||
app.mount("#app"); | |||
/** | |||
* Dropdown menus | |||
*/ | |||
document.addEventListener("click", (event) => { | |||
let dropdownMenus = document.querySelectorAll(".dropdown.active").forEach((elem) => { | |||
elem.classList.remove("active"); | |||
}); | |||
}); |
@ -1,190 +0,0 @@ | |||
import { IApiDataResponse } from "@common/api_schema"; | |||
import { createRouter, createWebHistory, NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router"; | |||
import store, { Action } from "../store"; | |||
/** | |||
* Check if the user is a guest; redirect otherwise | |||
*/ | |||
function requiresGuest(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) { | |||
if (store.getters.isAuthenticated) { | |||
console.log("Checking guest..."); | |||
next({ name: "Home" }); | |||
return; | |||
} | |||
next(); | |||
} | |||
/** | |||
* Check if the user is authenticated; redirect otherwise | |||
*/ | |||
function requiresAuth(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) { | |||
if (!store.getters.isAuthenticated) { | |||
next({ name: "Login" }); | |||
return; | |||
} | |||
next(); | |||
} | |||
/** | |||
* Check if the user is an authenticated admin, redirect otherwise | |||
*/ | |||
function requiresAdmin(to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) { | |||
if (store.getters.isAuthenticated && !store.getters.isAdmin) { | |||
next({ name: "Home" }); | |||
return; | |||
} | |||
next(); | |||
} | |||
/** | |||
* Fetch request providing authentication and logout upon unauthorized requests | |||
*/ | |||
export async function authFetch(path: string, options: RequestInit = {}): Promise<Response> { | |||
// Ensure the user is logged in | |||
let token = store.getters.token; | |||
if (token == null) { | |||
router.push({ name: "Login" }); | |||
throw Error("Unauthorized"); | |||
} | |||
// Perform the request | |||
options.credentials = "include"; | |||
options.headers = Object.assign(options.headers ?? {}, { | |||
"Authorization": `Bearer ${token}` | |||
}); | |||
let response = await fetch(path, options); | |||
// If an unauthorized gets thrown, forget the user and redirect to login | |||
if (response.status === 401) { | |||
store.dispatch(Action.AuthForget, undefined); | |||
router.push({ name: "Login" }); | |||
throw Error("Unauthorized"); | |||
} | |||
return response; | |||
} | |||
/** | |||
* Perform an authorized API request returning a JSON object | |||
*/ | |||
export async function authFetchApi<T>(path: string, options: RequestInit = {}) { | |||
let response = await authFetch(path, options); | |||
return <Promise<IApiDataResponse<T>>>response.json(); | |||
} | |||
const routes: RouteRecordRaw[] = [ | |||
{ | |||
path: "/", | |||
name: "Home", | |||
redirect: to => ({ name: "Dashboard" }) | |||
}, | |||
{ | |||
path: "/dashboard", | |||
name: "Dashboard", | |||
component: () => import("../views/Dashboard.vue"), | |||
beforeEnter: requiresAuth | |||
}, | |||
{ | |||
path: "/search", | |||
name: "Search", | |||
component: () => import("../views/Search.vue"), | |||
beforeEnter: requiresAuth, | |||
children: [ | |||
{ | |||
path: ":tmdbId", | |||
name: "Lookup", | |||
component: () => import("../components/modals/MovieModal.vue"), | |||
props: true | |||
} | |||
] | |||
}, | |||
{ | |||
path: "/admin", | |||
name: "Admin", | |||
redirect: to => ({ name: "AdminDashboard" }), | |||
component: () => import("../views/admin/Admin.vue"), | |||
beforeEnter: requiresAdmin, | |||
children: [ | |||
{ | |||
path: "dashboard", | |||
name: "AdminDashboard", | |||
component: () => import("../views/admin/AdminDashboard.vue"), | |||
props: true | |||
} | |||
] | |||
}, | |||
// Auth Routes --------------------------------------------------------------------------------- | |||
{ | |||
path: "/login", | |||
name: "Login", | |||
component: () => import("../views/Login.vue"), | |||
beforeEnter: requiresGuest, | |||
meta: { | |||
disableNavBar: true | |||
} | |||
}, | |||
{ | |||
path: "/register", | |||
name: "Register", | |||
component: () => import("../views/Register.vue"), | |||
beforeEnter: requiresGuest, | |||
meta: { | |||
disableNavBar: true | |||
} | |||
}, | |||
{ | |||
path: "/logout", | |||
name: "Logout", | |||
component: { | |||
beforeRouteEnter(to, from, next) { | |||
store.dispatch(Action.AuthForget, undefined); | |||
next({ name: "Login" }); | |||
} | |||
} | |||
}, | |||
{ | |||
path: "/link/discord/:token", | |||
name: "LinkDiscord", | |||
component: () => import("../views/LinkDiscord.vue"), | |||
meta: { | |||
disableNavBar: true | |||
} | |||
}, | |||
{ | |||
path: "/:pathMatch(.*)*", | |||
component: () => import("../views/Error404.vue") | |||
} | |||
]; | |||
const router = createRouter({ | |||
history: createWebHistory(), | |||
routes, | |||
}); | |||
/** | |||
* Guard authentication and guest routes | |||
*/ | |||
router.beforeEach((to, from, next) => { | |||
if (to.matched.some(record => record.meta.requiresAuth)) { | |||
if (localStorage.getItem("jwt") == null) { | |||
next({ name: "Login" }); | |||
return; | |||
} | |||
let user = JSON.parse(<any>localStorage.getItem("user")); | |||
if (to.matched.some(record => record.meta.requiresAdmin)) { | |||
if (!user.isAdmin) { | |||
next({ name: "Home" }); | |||
return; | |||
} | |||
} | |||
} else if (to.matched.some(record => record.meta.guest)) { | |||
if (localStorage.getItem("jwt") != null) { | |||
next({ name: "Home" }); | |||
return; | |||
} | |||
} | |||
next(); | |||
}); | |||
export default router; |
@ -1,5 +0,0 @@ | |||
declare module '*.vue' { | |||
import { DefineComponent } from 'vue' | |||
const component: DefineComponent<{}, {}, any> | |||
export default component | |||
} |
@ -1,9 +0,0 @@ | |||
import { ComponentCustomProperties } from 'vue' | |||
import type { Store } from "./store"; | |||
declare module '@vue/runtime-core' { | |||
// provide typings for `this.$store` | |||
interface ComponentCustomProperties { | |||
$store: Store | |||
} | |||
} |
@ -1,132 +0,0 @@ | |||
import { ActionTree } from "vuex"; | |||
import { Actions } from "./generics"; | |||
import { IState } from "./state"; | |||
import { Mutation, MutationsTypes } from "./mutations"; | |||
import router, { authFetch, authFetchApi } from "../routes"; | |||
import { GettersTypes } from "./getters"; | |||
import { IApiDataResponse } from "@common/api_schema"; | |||
// Payload types | |||
type IAuthenticatePayload = { email: string, password: string, remember: boolean }; | |||
type IAuthenticateResult = { email?: string[], password?: string[] } | null; | |||
type IAuthFetchPayload = { path: string, options?: RequestInit }; | |||
/** | |||
* The available actions te perform | |||
*/ | |||
export enum Action { | |||
// Authentication | |||
AuthAuthenticate = "AUTH_AUTHENTICATE", | |||
AuthForget = "AUHT_FORGET", | |||
AuthLoad = "AUTH_LOAD", | |||
// RESTful | |||
AuthFetch = "AUTH_FETCH", | |||
RequestMovie = "REQUEST_MOVIE" | |||
} | |||
/** | |||
* The action function signatures | |||
*/ | |||
export type ActionsTypes = { | |||
// Authentication | |||
[Action.AuthAuthenticate]: (payload: IAuthenticatePayload) => Promise<IAuthenticateResult>, | |||
[Action.AuthForget] : () => void, | |||
[Action.AuthLoad] : () => boolean, | |||
// RESTful | |||
[Action.AuthFetch] : (payload: IAuthFetchPayload) => Promise<Response>, | |||
[Action.RequestMovie]: (tmdbId: number | string) => Promise<IApiDataResponse<{ ticketId: number }>> | |||
} | |||
/** | |||
* The action function implementations | |||
*/ | |||
export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes> & ActionTree<IState, IState> = { | |||
// Authentication ------------------------------------------------------------------------------ | |||
/** | |||
* Authenticate the credentials of a user and log them in | |||
*/ | |||
[Action.AuthAuthenticate]({commit, dispatch}, {email, password, remember = false}) { | |||
return new Promise((resolve, reject) => { | |||
fetch(`/auth/login?use_cookies=${navigator.cookieEnabled}`, { | |||
method: "post", | |||
headers: { "Content-Type": "application/json" }, | |||
body: JSON.stringify({email, password}) | |||
}) | |||
.then(async response => { | |||
let body = await response.json(); | |||
if (response.status !== 200) { | |||
if (response.status === 401) { | |||
body.errors = { "email": [ "Email or password is incorrect" ] }; | |||
} | |||
resolve(body.errors || {}); | |||
return; | |||
} | |||
commit(Mutation.UserLoad, body.token); | |||
commit(Mutation.UserStore, remember); | |||
resolve(null); | |||
}) | |||
.catch(e => { | |||
console.error("Error occurred during submission:", e); | |||
reject(e); | |||
}); | |||
}); | |||
}, | |||
/** | |||
* Forget the login token and log the user out | |||
*/ | |||
[Action.AuthForget]({commit}) { | |||
commit(Mutation.UserForget, undefined); | |||
}, | |||
/** | |||
* Load the user from local storage | |||
*/ | |||
[Action.AuthLoad]({getters, commit, dispatch}) { | |||
let token = getters.storedToken; | |||
if (!token) { | |||
return false; | |||
} | |||
try { | |||
commit(Mutation.UserLoad, token); | |||
} catch(e) { | |||
dispatch(Action.AuthForget, undefined); | |||
return false; | |||
} | |||
return true; | |||
}, | |||
// RESTful ------------------------------------------------------------------------------------- | |||
/** | |||
* Fetch request providing authentication and logout upon unauthorized requests | |||
*/ | |||
async [Action.AuthFetch]({commit, state}, {path, options = {}}) { | |||
if (state.user == null) { | |||
router.push({ name: "Login" }); | |||
throw Error("Unauthorized"); | |||
} | |||
options.credentials = "include"; | |||
options.headers = Object.assign(options.headers ?? {}, { | |||
"Authorization": `Bearer ${state.user.token}` | |||
}); | |||
let response = await fetch(path, options); | |||
if (response.status === 401) { | |||
commit(Mutation.UserForget, undefined); | |||
router.push({ name: "Login" }); | |||
throw Error("Unauthorized"); | |||
} | |||
return response; | |||
}, | |||
/** | |||
* Request a movie | |||
*/ | |||
async [Action.RequestMovie](_, tmdbId) { | |||
return await authFetchApi(`/api/movie/request/create/tmdb/${tmdbId}`); | |||
} | |||
}; |
@ -1,33 +0,0 @@ | |||
import { ActionContext, CommitOptions, DispatchOptions, GetterTree, MutationTree, Store as VuexStore } from "vuex"; | |||
type AugmentedActionContext<S, G extends GetterTree<S, S>, M extends MutationTree<S>, A extends ActionsType> = Omit<ActionContext<S, S>, "commit" | "dispatch" | "getters"> & { | |||
commit<K extends keyof M>(key: K, payload: Parameters<M[K]>[1]): ReturnType<M[K]>, | |||
dispatch<K extends keyof A, P extends Parameters<A[K]>[0]>( | |||
key: K, | |||
payload: P, | |||
options?: DispatchOptions | |||
): ReturnType<A[K]>, | |||
getters: { [K in keyof G]: ReturnType<G[K]> } | |||
}; | |||
type ActionsType = { | |||
[key: string]: (payload?: any) => any | |||
}; | |||
export type Actions<S, G extends GetterTree<S, S>, M extends MutationTree<S>, A extends ActionsType> = { | |||
[K in keyof A]: (context: AugmentedActionContext<S, G, M, A>, payload: Parameters<A[K]>[0]) => ReturnType<A[K]> | |||
} | |||
export type GenericStore<S, G extends GetterTree<S, S>, M extends MutationTree<S>, A extends ActionsType> = Omit<VuexStore<S>, "commit" | "getters" | "dispatch"> & { | |||
commit<K extends keyof M, P extends Parameters<M[K]>[1]>( | |||
key: K, | |||
payload: P, | |||
options?: CommitOptions | |||
): ReturnType<M[K]>, | |||
getters: { [K in keyof G]: ReturnType<G[K]> }, | |||
dispatch<K extends keyof A, P extends Parameters<A[K]>[0]>( | |||
key: K, | |||
payload: P, | |||
options?: DispatchOptions | |||
): ReturnType<A[K]> | |||
}; |
@ -1,47 +0,0 @@ | |||
import { GetterTree } from "vuex"; | |||
import { IState } from "./state"; | |||
export type GettersTypes = { | |||
isAdmin (state: IState): boolean, | |||
isAuthenticated(state: IState): boolean, | |||
storedToken () : string | null, | |||
token (state: IState): string | null, | |||
userName (state: IState): string | null | |||
} | |||
export const getters: GettersTypes & GetterTree<IState, IState> = { | |||
/** | |||
* Determine if the current user is authenticated | |||
*/ | |||
isAuthenticated(state: IState) { | |||
return state.user != null; | |||
}, | |||
/** | |||
* Determine if the user is an admin | |||
*/ | |||
isAdmin(state: IState) { | |||
return state.user?.isAdmin ?? false; | |||
}, | |||
/** | |||
* Retrieve the stored token | |||
*/ | |||
storedToken() { | |||
return sessionStorage.getItem("jwt") ?? localStorage.getItem("jwt"); | |||
}, | |||
/** | |||
* Get the current token | |||
*/ | |||
token(state: IState) { | |||
return state.user?.token ?? null; | |||
}, | |||
/** | |||
* Get the user's name (assumes authenticated) | |||
*/ | |||
userName(state: IState) { | |||
return state.user?.name.split(" ")[0] ?? null; | |||
} | |||
}; |
@ -1,36 +0,0 @@ | |||
import { createLogger, createStore } from "vuex"; | |||
import { GenericStore } from "./generics"; | |||
import { getters, GettersTypes } from "./getters"; | |||
// Import store information | |||
import { state, IState } from "./state"; | |||
import { mutations, MutationsTypes } from "./mutations"; | |||
import { actions, ActionsTypes } from "./actions"; | |||
// Export store keys | |||
export { Mutation } from "./mutations"; | |||
export { Action } from "./actions"; | |||
/** | |||
* The the store type | |||
*/ | |||
export type Store = GenericStore<IState, GettersTypes, MutationsTypes, ActionsTypes>; | |||
/** | |||
* Create the store instance | |||
*/ | |||
const store = createStore({ | |||
state, getters, mutations, actions, plugins: [createLogger()] | |||
}) as Store; | |||
/** | |||
* Use the store | |||
*/ | |||
export function useStore() { | |||
return store; | |||
} | |||
/** | |||
* Export the store instance by default | |||
*/ | |||
export default store; |
@ -1,92 +0,0 @@ | |||
import Modal from "@app/components/modals"; | |||
import jwtDecode from "jwt-decode"; | |||
import { MutationTree } from "vuex"; | |||
import { IState } from "./state"; | |||
/** | |||
* All available mutation types | |||
*/ | |||
export enum Mutation { | |||
LockScroll = "LOCK_SCROLL", | |||
UserForget = "USER_FORGET", | |||
UserLoad = "USER_LOAD", | |||
UserStore = "USER_STORE", | |||
} | |||
/** | |||
* Type declarations for each mutation | |||
*/ | |||
export type MutationsTypes<S = IState> = { | |||
[Mutation.UserForget]: (state: S) => void, | |||
[Mutation.UserLoad] : (state: S, token: string) => boolean, | |||
[Mutation.UserStore] : (state: S, remember: boolean) => void, | |||
[Mutation.LockScroll]: (state: S, lock: boolean) => void, | |||
} | |||
/** | |||
* The auth mutations | |||
*/ | |||
export const mutations: MutationsTypes & MutationTree<IState> = { | |||
[Mutation.LockScroll](state: IState, lock: boolean) { | |||
if (lock) { | |||
// document.body.style.position = "fixed"; | |||
// document.body.style.top = `-${window.scrollY}px`; | |||
document.body.style.paddingRight = `${window.innerWidth - document.body.clientWidth}px`; | |||
document.body.style.overflow = "hidden"; | |||
} else { | |||
// const scrollY = document.body.style.top; | |||
// document.body.style.position = ""; | |||
// document.body.style.top = ""; | |||
document.body.style.paddingRight = ""; | |||
document.body.style.overflow = ""; | |||
// window.scrollTo(0, parseInt(scrollY || '0') * -1); | |||
} | |||
}, | |||
/** | |||
* Log the user out and remove the token from storage | |||
*/ | |||
[Mutation.UserForget](state: IState) { | |||
state.user = null; | |||
sessionStorage.removeItem("jwt"); | |||
localStorage.removeItem("jwt"); | |||
}, | |||
/** | |||
* Try loading a user from the provided token | |||
*/ | |||
[Mutation.UserLoad](state: IState, token: string) { | |||
try { | |||
let user: any = jwtDecode(token); | |||
state.user = { | |||
id : user.id, | |||
name : user.name, | |||
isAdmin: user.isAdmin, | |||
token : token | |||
} | |||
} catch(e) { | |||
return false; | |||
} | |||
return true; | |||
}, | |||
/** | |||
* Store the user to remember them | |||
*/ | |||
[Mutation.UserStore](state: IState, remember: boolean) { | |||
if (state.user == null) { | |||
sessionStorage.removeItem("jwt"); | |||
localStorage.removeItem("jwt"); | |||
return; | |||
} | |||
if (remember) { | |||
localStorage.setItem("jwt", state.user.token); | |||
} else { | |||
sessionStorage.setItem("jwt", state.user.token); | |||
} | |||
} | |||
} |
@ -1,6 +0,0 @@ | |||
export interface IUser { | |||
id : number, | |||
name : string, | |||
isAdmin: boolean, | |||
token : string | |||
} |
@ -1,21 +0,0 @@ | |||
import { IUser } from "./schema"; | |||
import type Modals from "../components/modals"; | |||
import Modal from "../components/modals"; | |||
/** | |||
* The state definition | |||
*/ | |||
export interface IState { | |||
user : IUser | null, | |||
modalName : Modal | null, | |||
modalVisible: boolean | |||
} | |||
/** | |||
* The state implementation | |||
*/ | |||
export const state: IState = { | |||
user: null, | |||
modalName: null, | |||
modalVisible: false, | |||
}; |
@ -1,42 +0,0 @@ | |||
@import '@fortawesome/fontawesome-free/css/all.css'; | |||
@tailwind base; | |||
@tailwind components; | |||
@tailwind utilities; | |||
html, body { | |||
@apply h-full; | |||
} | |||
.fade-enter-active, .fade-leave-active { | |||
transition: opacity 0.15s; | |||
} | |||
.fade-enter-from, .fade-leave-to { | |||
opacity: 0; | |||
} | |||
.slide-enter-active, .slide-leave-active { | |||
transform: none; | |||
opacity: 1.0; | |||
transition: opacity 0.15s ease-in-out, transform 0.5s cubic-bezier(0.0, 0.0, 0.2, 1); | |||
} | |||
.slide-enter-from, .slide-leave-to { | |||
opacity: 0; | |||
transition: opacity 0.15s ease-in-out, transform 0.5s cubic-bezier(0.4, 0.0, 1, 1); | |||
transform: translateY(10%); | |||
} | |||
.slide-left-enter-active, .slide-left-leave-active { | |||
transform: none; | |||
transition: transform 0.25s cubic-bezier(0.0, 0.0, 0.2, 1); | |||
} | |||
.slide-left-enter-from, .slide-left-leave-to { | |||
transition: transform 0.25s cubic-bezier(0.4, 0.0, 1, 1); | |||
transform: translateX(-100%); | |||
} | |||
.modal { | |||
@apply fixed inset-0 flex flex-col h-screen; | |||
background: rgba(0, 0, 0, 0.5); | |||
} |
@ -1,51 +0,0 @@ | |||
import { single as validate } from "validate.js"; | |||
export function validateValue(value: string, constraints: any) { | |||
return <string>(validate(value || null, constraints) ?? [""])[0]; | |||
} | |||
export function getAverageRgb(imgEl: HTMLImageElement) { | |||
var blockSize = 5, // only visit every 5 pixels | |||
defaultRGB = {r:0,g:0,b:0}, // for non-supporting envs | |||
canvas = document.createElement('canvas'), | |||
context = canvas.getContext && canvas.getContext('2d'), | |||
data, width, height, | |||
i = -4, | |||
length, | |||
rgb = {r:0,g:0,b:0}, | |||
count = 0; | |||
if (!context) { | |||
return defaultRGB; | |||
} | |||
height = canvas.height = imgEl.naturalHeight || imgEl.offsetHeight || imgEl.height; | |||
width = canvas.width = imgEl.naturalWidth || imgEl.offsetWidth || imgEl.width; | |||
context.drawImage(imgEl, 0, 0); | |||
try { | |||
data = context.getImageData(0, 0, width, height); | |||
} catch(e) { | |||
/* security error, img on diff domain */alert('x'); | |||
return defaultRGB; | |||
} | |||
length = data.data.length; | |||
while ( (i += blockSize * 4) < length ) { | |||
++count; | |||
rgb.r += data.data[i]; | |||
rgb.g += data.data[i+1]; | |||
rgb.b += data.data[i+2]; | |||
} | |||
// ~~ used to floor values | |||
rgb.r = ~~(rgb.r/count); | |||
rgb.g = ~~(rgb.g/count); | |||
rgb.b = ~~(rgb.b/count); | |||
return rgb; | |||
} |
@ -1,43 +0,0 @@ | |||
<template> | |||
<div class="h-12 text-2xl flex items-center"> | |||
<h1 class="">Active Requests</h1> | |||
</div> | |||
<movie-list :movies="activeRequests" @onClickMovie="displayMovie"/> | |||
<movie-modal :tmdb-id="activeTmdb" @onClose="activeTmdb = undefined"/> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
import { authFetchApi } from "../routes"; | |||
import MovieList from "../components/MovieList.vue"; | |||
import MovieModal from "../components/modals/MovieModal.vue"; | |||
import { IApiMovie } from "../../common/api_schema"; | |||
export default defineComponent({ | |||
components: { | |||
MovieList, | |||
MovieModal | |||
}, | |||
data() { | |||
return { | |||
activeRequests: <IApiMovie[]>[], | |||
activeTmdb: <number|undefined> undefined | |||
} | |||
}, | |||
methods: { | |||
displayMovie(movie: IApiMovie, index: number) { | |||
this.activeTmdb = movie.tmdbId; | |||
}, | |||
fetchRequests() { | |||
return authFetchApi<IApiMovie[]>("/api/movie/request/tickets/active").then((response) => { | |||
this.activeRequests = response.data; | |||
}).catch(() => { | |||
console.error("Failed to fetch active movie tickets"); | |||
}); | |||
} | |||
}, | |||
mounted() { | |||
this.fetchRequests(); | |||
} | |||
}); | |||
</script> |
@ -1,6 +0,0 @@ | |||
<template> | |||
<div class="mx-auto my-auto"> | |||
<h1 class="text-9xl font-black opacity-30">404</h1> | |||
</div> | |||
</template> | |||
@ -1,99 +0,0 @@ | |||
<template> | |||
<div v-if="success" class="w-full sm:max-w-sm sm:mx-auto sm:my-auto p-6 space-y-4 bg-white rounded-lg shadow-md text-center"> | |||
<div class="text-center font-thin text-4xl pt-4">AUTOPLEX</div> | |||
<div> | |||
<i class="fas fa-check-circle align-middle text-green-500 text-4xl"></i> | |||
</div> | |||
<p>Discord account linked successfully! You may close this tab now</p> | |||
</div> | |||
<div v-else class="w-full sm:max-w-sm sm:mx-auto sm:my-auto p-6 space-y-4 bg-white rounded-lg shadow-md"> | |||
<div class="text-center font-thin text-4xl pt-4">AUTOPLEX</div> | |||
<div> | |||
<p class="opacity-70 text-sm text-center">Enter your credientials to complete linking your Discord account</p> | |||
</div> | |||
<form @submit.prevent="authenticate"> | |||
<div class="space-y-4"> | |||
<div> | |||
<text-box label="Email" type="email" ref="email" placeholder="john@example.com" :disabled="isSubmitting" | |||
v-model="fields.email"/> | |||
</div> | |||
<div> | |||
<text-box label="Password" type="password" ref="password" placeholder="············" :disabled="isSubmitting" | |||
v-model="fields.password"/> | |||
</div> | |||
<div> | |||
<button :disabled="isSubmitting" class="block w-full rounded-full bg-indigo-500 text-white p-2 focus:outline-none">Link Discord Account</button> | |||
</div> | |||
</div> | |||
</form> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
import TextBox from "../components/TextBox.vue"; | |||
export default defineComponent({ | |||
components: { | |||
TextBox | |||
}, | |||
data() { | |||
return { | |||
isSubmitting: false, | |||
success: false, | |||
fields: { | |||
email: "", | |||
password: "" | |||
}, | |||
} | |||
}, | |||
methods: { | |||
async authenticate() { | |||
if (this.isSubmitting) { | |||
return; | |||
} | |||
this.isSubmitting = true; | |||
try { | |||
// Send the request and wait far the response | |||
let response = await fetch(`/auth/link/discord/${this.$route.params["token"]}`, { | |||
method: "post", | |||
headers: { "Content-Type": "application/json" }, | |||
body: JSON.stringify(this.fields) | |||
}); | |||
this.isSubmitting = false; | |||
// Check the response for errors | |||
let body = await response.json(); | |||
if (response.status !== 200) { | |||
if (response.status === 401) { | |||
body.errors = { "email": [ "Email or password is incorrect" ] }; | |||
} | |||
for (let fieldName of ["email", "password"]) { | |||
let field = <any>this.$refs[fieldName]; | |||
let message = <string>((<any>body.errors)[fieldName] ?? [""])[0]; | |||
field.setErrorMessage(message); | |||
} | |||
return; | |||
} | |||
} catch(e) { | |||
console.error("Something went wrong logging in:", e); | |||
this.isSubmitting = false; | |||
} | |||
this.success = true; | |||
} | |||
}, | |||
async beforeRouteEnter(to, from, next) { | |||
let token = to.params["token"]; | |||
if (typeof token !== "string") { | |||
next({ name: "Home" }); | |||
return; | |||
} | |||
// Verify that the token is valid. Redirect otherwise | |||
let response = await fetch(`/auth/link/verify/discord/${token}`, { method: "post" }); | |||
if (response.status !== 200) { | |||
next({ name: "Home" }); | |||
return; | |||
} | |||
next(); | |||
} | |||
}); | |||
</script> |
@ -1,77 +0,0 @@ | |||
<template> | |||
<div class="w-full sm:max-w-sm sm:mx-auto sm:my-auto p-6 space-y-4 bg-white rounded-lg shadow-md"> | |||
<div class="text-center font-thin text-4xl py-4">AUTOPLEX</div> | |||
<div class="font-medium text-center text-xl">Sign in</div> | |||
<form @submit.prevent="login"> | |||
<div class="space-y-4"> | |||
<div> | |||
<text-box label="Email" type="email" ref="email" placeholder="john@example.com" :disabled="isSubmitting" | |||
v-model="fields.email"/> | |||
</div> | |||
<div> | |||
<text-box label="Password" type="password" ref="password" placeholder="············" :disabled="isSubmitting" | |||
v-model="fields.password"/> | |||
</div> | |||
<div> | |||
<check-box label="Remember Me" :disabled="isSubmitting" v-model="fields.remember"/> | |||
</div> | |||
<div> | |||
<button :disabled="isSubmitting" class="block w-full rounded-full bg-indigo-500 text-white p-2 focus:outline-none">Sign In</button> | |||
</div> | |||
</div> | |||
</form> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
import { Action } from "../store"; | |||
import CheckBox from "../components/CheckBox.vue"; | |||
import TextBox from "../components/TextBox.vue"; | |||
export default defineComponent({ | |||
components: { | |||
CheckBox, | |||
TextBox | |||
}, | |||
data() { | |||
return { | |||
isSubmitting: false, | |||
fields: { | |||
email: "", | |||
password: "", | |||
remember: false | |||
}, | |||
} | |||
}, | |||
methods: { | |||
async login() { | |||
if (this.isSubmitting) { | |||
return; | |||
} | |||
this.isSubmitting = true; | |||
try { | |||
let errors = await this.$store.dispatch(Action.AuthAuthenticate, this.fields); | |||
if (errors) { | |||
for (let fieldName of ["email", "password"]) { | |||
let field = <any>this.$refs[fieldName]; | |||
let message = <string>((<any>errors)[fieldName] ?? [""])[0]; | |||
field.setErrorMessage(message); | |||
} | |||
} else { | |||
this.$router.push({ name: "Home" }); | |||
} | |||
} catch(e) { | |||
console.error("Something went wrong logging in:", e); | |||
} | |||
this.isSubmitting = false; | |||
} | |||
} | |||
}); | |||
</script> | |||
<style lang="postcss"> | |||
button:focus-visible { | |||
@apply ring-2 ring-indigo-500 ring-offset-2; | |||
} | |||
</style> |
@ -1,156 +0,0 @@ | |||
<template> | |||
<div class="w-full sm:max-w-sm sm:mx-auto sm:my-auto p-6 space-y-4 bg-white rounded-lg shadow-md"> | |||
<div class="text-center font-thin text-4xl py-4">AUTOPLEX</div> | |||
<div class="font-medium text-center text-xl">Sign Up</div> | |||
<form @submit.prevent="register"> | |||
<div class="space-y-4"> | |||
<div> | |||
<text-box label="Access Token" type="text" disabled | |||
v-model="fields.token" :validator="validateToken" ref="token"/> | |||
</div> | |||
<div> | |||
<text-box label="Your Name" type="text" placeholder="John Doe" :disabled="isSubmitting" | |||
v-model="fields.name" :validator="constraints.name" ref="name"/> | |||
</div> | |||
<div> | |||
<text-box label="Email" type="email" placeholder="john@example.com" :disabled="isSubmitting" | |||
v-model="fields.email" :validator="validateEmail" ref="email"/> | |||
</div> | |||
<div> | |||
<text-box label="Password" type="password" placeholder="············" :disabled="isSubmitting" | |||
v-model="fields.password" :validator="constraints.password" ref="password" | |||
/> | |||
</div> | |||
<div> | |||
<text-box label="Re-type Password" type="password" placeholder="············" :disabled="isSubmitting" | |||
v-model="fields.retypePassword" :validator="validateRetypePassword" ref="retypePassword"/> | |||
</div> | |||
<div> | |||
<button type="submit" class="block w-full rounded-full bg-indigo-500 text-white p-3 focus:outline-none" :disabled="isSubmitting">Register</button> | |||
</div> | |||
</div> | |||
</form> | |||
</div> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent, reactive } from "vue"; | |||
import CheckBox from "../components/CheckBox.vue"; | |||
import TextBox from "../components/TextBox.vue"; | |||
import { constraints } from "../../common/validation"; | |||
import { validateValue } from "../util"; | |||
export default defineComponent({ | |||
components: { | |||
CheckBox, | |||
TextBox | |||
}, | |||
data() { | |||
return { | |||
isSubmitting : false, | |||
fields: reactive({ | |||
token : <string>this.$route.query["token"], | |||
name : "", | |||
email : "", | |||
password : "", | |||
retypePassword: "", | |||
}), | |||
constraints: { | |||
name: constraints.register.name, | |||
email: constraints.register.email, | |||
password: constraints.register.password | |||
} | |||
} | |||
}, | |||
methods: { | |||
/** | |||
* Submit the registration form | |||
*/ | |||
async register() { | |||
if (this.isSubmitting) { | |||
return; | |||
} | |||
this.isSubmitting = true; | |||
fetch(`/auth/register`, { | |||
method: "post", | |||
headers: { | |||
"Content-Type": "application/json" | |||
}, | |||
body: JSON.stringify(this.fields) | |||
}) | |||
.then(async response => { | |||
this.isSubmitting = false; | |||
if (response.status !== 200) { | |||
let body = await response.json(); | |||
if (body.errors) { | |||
for (let fieldName in this.fields) { | |||
let field = <any>this.$refs[fieldName]; | |||
let message = <string>(body.errors[fieldName] ?? [""])[0]; | |||
field.setErrorMessage(message); | |||
} | |||
} | |||
return; | |||
} | |||
this.$router.push({ name: "Login" }); | |||
}) | |||
.catch(e => { | |||
console.error("Error occurred during submission:", e); | |||
this.isSubmitting = false; | |||
}); | |||
}, | |||
/** | |||
* Validate the provided registration token | |||
*/ | |||
async validateToken(token: string) { | |||
let response = await fetch(`/auth/register/validate_token/${token}`, { method: "post" }); | |||
if (response.status !== 200) { | |||
return "A valid token is required to register"; | |||
} | |||
}, | |||
/** | |||
* Validate the provided email address field | |||
*/ | |||
async validateEmail(email: string) { | |||
let error = validateValue(email, constraints.register.email); | |||
if (error) { | |||
return error; | |||
} | |||
// Check email availability | |||
let response = await fetch(`/auth/register/available_email/${email}`, { method: "post" }); | |||
if (response.status !== 200) { | |||
return "An account with that email address already exists"; | |||
} | |||
}, | |||
/** | |||
* Validate the provided retype-password field | |||
*/ | |||
validateRetypePassword(value: string) { | |||
if (value.trim().length == 0) { | |||
return constraints.register.retypePassword.presence.message; | |||
} | |||
if (value !== this.fields.password) { | |||
return constraints.register.retypePassword.equality.message; | |||
} | |||
} | |||
}, | |||
/** | |||
* Navigation Guard | |||
*/ | |||
beforeRouteEnter(to, from, next) { | |||
if (to.query["token"] === undefined) { | |||
next({ name: "Login" }); | |||
} else { | |||
next(); | |||
} | |||
} | |||
}); | |||
</script> | |||
<style lang="postcss"> | |||
button:focus-visible { | |||
@apply ring-2 ring-indigo-500 ring-offset-2; | |||
} | |||
</style> |
@ -1,102 +0,0 @@ | |||
<template> | |||
<div class="space-y-4 lg:p-4"> | |||
<form @submit.prevent="() => search()"> | |||
<div class="flex rounded-xl overflow-hidden shadow-md"> | |||
<input type="text" v-model="searchValue" :placeholder="exampleTitles[Math.floor(exampleTitles.length*Math.random())]" | |||
class="w-full outline-none p-2 bg-white border border-gray-100 text-gray-800 placeholder-gray-400 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-gray-50 disabled:border-gray-400"> | |||
<button class="py-3 px-6 bg-indigo-500 text-white" type="submit">Search</button> | |||
</div> | |||
</form> | |||
<div v-if="$route.query['query']" class="text-center">{{ movies.length }} result{{ movies.length != 1 ? 's' : ""}} found</div> | |||
<movie-list :movies="movies" @onClickMovie="displayMovie"/> | |||
</div> | |||
<router-view @onClose="onModalClosed"></router-view> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
import { IApiMovie, IApiPaginatedResponse } from "@common/api_schema"; | |||
import { authFetchApi } from "../routes"; | |||
import MovieList from "../components/MovieList.vue"; | |||
export default defineComponent({ | |||
components: { | |||
MovieList | |||
}, | |||
data() { | |||
return { | |||
exampleTitles: ["John Wick", "The Incredibles 2", "Stand by Me", "Shawshank Redemption", "The Dark Knight", "Pulp Fiction", "Forrest Gump"], | |||
isSubmitting: false, | |||
movies: <IApiMovie[]>[], | |||
searchValue: <string>this.$route.query["query"] || "", | |||
page: -1, | |||
totalPages: 0, | |||
totalResults: 0 | |||
} | |||
}, | |||
methods: { | |||
displayMovie(movie: IApiMovie) { | |||
this.$router.push({ | |||
name : "Lookup", | |||
params: { tmdbId: movie.tmdbId }, | |||
query : this.$route.query | |||
}); | |||
}, | |||
onModalClosed() { | |||
this.$router.push({ name: "Search", query: this.$route.query }); | |||
}, | |||
async search(pushRoute: boolean = true) { | |||
if (this.isSubmitting || this.searchValue.trim().length == 0) { | |||
return; | |||
} | |||
if (document.activeElement && (<any>document.activeElement).blur) { | |||
(<any>document.activeElement).blur(); | |||
} | |||
if (pushRoute) { | |||
this.$router.push({ name: "Search", query: { query: this.searchValue } }); | |||
} | |||
this.isSubmitting = true; | |||
try { | |||
let response = await authFetchApi<IApiPaginatedResponse<IApiMovie>>(`/api/movie/search?query=${encodeURI(this.searchValue)}`); | |||
this.movies = response.data.results; | |||
this.page = response.data.page; | |||
this.totalPages = response.data.totalPages; | |||
this.totalResults = response.data.totalResults; | |||
console.log("Got results", this.totalResults); | |||
} catch(e) { | |||
console.log("Error fetching movies:", e); | |||
} | |||
this.isSubmitting = false; | |||
} | |||
}, | |||
mounted() { | |||
this.search(false); | |||
}, | |||
}); | |||
</script> | |||
<style scoped> | |||
.overlay { | |||
position: relative; | |||
} | |||
.overlay::before { | |||
content: ""; | |||
position: absolute; | |||
left: 0; top: 0; | |||
right: 0; | |||
bottom: 0; | |||
background: rgba(0, 0, 0, 0.5); | |||
filter: grayscale(100%); | |||
z-index: 0; | |||
} | |||
.overlay > * { | |||
z-index: 1; | |||
} | |||
.active .body { | |||
display: block !important; | |||
} | |||
</style> |
@ -1,3 +0,0 @@ | |||
<template> | |||
<router-view/> | |||
</template> |
@ -1,3 +0,0 @@ | |||
<template> | |||
<div>Admin dashboard</div> | |||
</template> |
@ -1,60 +0,0 @@ | |||
/** | |||
* Basic user information schema | |||
*/ | |||
export interface IUser { | |||
id : number, | |||
name : string, | |||
isAdmin: boolean | |||
} | |||
/** | |||
* The JWT auth token structure | |||
*/ | |||
export interface ITokenSchema extends IUser { | |||
iat : number, | |||
exp : number | |||
} | |||
/** | |||
* The general API response structure | |||
*/ | |||
export interface IApiResponse { | |||
status: string | |||
} | |||
/** | |||
* A generic data response from the API | |||
*/ | |||
export interface IApiDataResponse<T> extends IApiResponse { | |||
data: T | |||
} | |||
export interface IApiPaginatedResponse<T> { | |||
page : number, | |||
results : T[], | |||
totalPages : number, | |||
totalResults: number | |||
}; | |||
/** | |||
* A movie listing returned from the API | |||
*/ | |||
export interface IApiMovie { | |||
plexLink : string | null, | |||
posterPath : string | null, | |||
releaseDate: string | null, | |||
ticketId : number | null, | |||
title : string, | |||
tmdbId : number | |||
} | |||
/** | |||
* Movie details returned from the API | |||
*/ | |||
export interface IApiMovieDetails extends IApiMovie { | |||
backdropPath: string | null, | |||
imdbId : string | null, | |||
overview : string | null, | |||
runtime : number | null, | |||
requestedBy : IUser | null | |||
} |
@ -1,90 +0,0 @@ | |||
export const constraints = { | |||
api: { | |||
movie: { | |||
search: { | |||
query: { | |||
presence: { | |||
allowEmpty: false, | |||
message: "The query cannot be blank" | |||
} | |||
}, | |||
year: { | |||
numericality: { | |||
onlyInteger: true, | |||
greaterThan: 0, | |||
notGreaterThan: "Invalid year", | |||
notValid: "Invalid year", | |||
notInteger: "Invalid year" | |||
} | |||
} | |||
} | |||
} | |||
}, | |||
login: { | |||
email: { | |||
presence: { | |||
allowEmpty: false, | |||
message: "An email address is required" | |||
} | |||
}, | |||
password: { | |||
presence: { | |||
allowEmpty: false, | |||
message: "A password is required" | |||
} | |||
} | |||
}, | |||
register: { | |||
token: { | |||
presence: { | |||
message: "A valid token is required to register" | |||
}, | |||
token: { | |||
message: "A valid token is required to register" | |||
} | |||
}, | |||
name: { | |||
presence: { | |||
allowEmpty: false, | |||
message: "Your name is required" | |||
}, | |||
length: { | |||
maximum: 50, | |||
tooLong: "Your name cannot exceed 50 characters" | |||
} | |||
}, | |||
email: { | |||
presence: { | |||
allowEmpty: false, | |||
message: "Your email is required" | |||
}, | |||
length: { | |||
maximum: 255, | |||
tooLong: "An email address cannot exceed 255 characters" | |||
}, | |||
email: { | |||
message: "A valid email address is required" | |||
} | |||
}, | |||
password: { | |||
presence: { | |||
allowEmpty: false, | |||
message: "A password is required" | |||
}, | |||
length: { | |||
minimum: 8, | |||
tooShort: "Password should be at least 8 characters" | |||
} | |||
}, | |||
retypePassword: { | |||
presence: { | |||
allowEmpty: false, | |||
message: "Re-type your password to confirm it" | |||
}, | |||
equality: { | |||
attribute: "password", | |||
message: "Passwords must match" | |||
} | |||
} | |||
} | |||
}; |
@ -1,27 +0,0 @@ | |||
import fastify from "fastify"; | |||
import fastifyStatic from "fastify-static"; | |||
import { join } from "path"; | |||
/** | |||
* Create the server instance | |||
*/ | |||
let server = fastify(); | |||
/** | |||
* Serve static assets | |||
*/ | |||
server.register(fastifyStatic, { | |||
root: join(__dirname, "../public") | |||
}); | |||
/** | |||
* If a file is not found, return the web UI | |||
*/ | |||
server.setNotFoundHandler((_, reply) => { | |||
return reply.sendFile("index.html"); | |||
}); | |||
/** | |||
* Serve the web UI | |||
*/ | |||
server.listen(3201, "0.0.0.0"); |
@ -1,30 +0,0 @@ | |||
const defaultTheme = require("tailwindcss/defaultTheme"); | |||
module.exports = { | |||
purge: ["./index.html", "src/app/**/*.{vue,ts}"], | |||
darkMode: false, // or 'media' or 'class' | |||
theme: { | |||
extend: { | |||
colors: { | |||
ui: { | |||
plexGray: "rgb(31, 35, 38)" | |||
} | |||
} | |||
}, | |||
screens: { | |||
xs: "475px", | |||
...defaultTheme.screens | |||
} | |||
}, | |||
variants: { | |||
extend: { | |||
cursor: ["disabled"], | |||
backgroundColor: ["disabled"], | |||
opacity: ["disabled"], | |||
borderWidth: ["disabled"], | |||
borderColor: ["disabled"], | |||
transform: ["motion-safe"] | |||
}, | |||
}, | |||
plugins: [], | |||
} |
@ -1,76 +0,0 @@ | |||
{ | |||
"compilerOptions": { | |||
/* Visit https://aka.ms/tsconfig.json to read more about this file */ | |||
/* Basic Options */ | |||
// "incremental": true, /* Enable incremental compilation */ | |||
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ | |||
// "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ | |||
// "lib": [], /* Specify library files to be included in the compilation. */ | |||
// "allowJs": true, /* Allow javascript files to be compiled. */ | |||
// "checkJs": true, /* Report errors in .js files. */ | |||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ | |||
// "declaration": true, /* Generates corresponding '.d.ts' file. */ | |||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ | |||
"sourceMap": true, /* Generates corresponding '.map' file. */ | |||
// "outFile": "./", /* Concatenate and emit output to single file. */ | |||
"outDir": "./dist", /* Redirect output structure to the directory. */ | |||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ | |||
// "composite": true, /* Enable project compilation */ | |||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ | |||
// "removeComments": true, /* Do not emit comments to output. */ | |||
// "noEmit": true, /* Do not emit outputs. */ | |||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */ | |||
"downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ | |||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ | |||
/* Strict Type-Checking Options */ | |||
"strict": true, /* Enable all strict type-checking options. */ | |||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ | |||
// "strictNullChecks": true, /* Enable strict null checks. */ | |||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */ | |||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ | |||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ | |||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ | |||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ | |||
/* Additional Checks */ | |||
// "noUnusedLocals": true, /* Report errors on unused locals. */ | |||
// "noUnusedParameters": true, /* Report errors on unused parameters. */ | |||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ | |||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ | |||
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ | |||
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ | |||
/* Module Resolution Options */ | |||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ | |||
"baseUrl": "./src", /* Base directory to resolve non-absolute module names. */ | |||
// "paths": {} /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ | |||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ | |||
"typeRoots": [ /* List of folders to include type definitions from. */ | |||
"src/typings" | |||
], | |||
// "types": [], /* Type declaration files to be included in compilation. */ | |||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ | |||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ | |||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ | |||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ | |||
/* Source Map Options */ | |||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ | |||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ | |||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ | |||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ | |||
/* Experimental Options */ | |||
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ | |||
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ | |||
/* Advanced Options */ | |||
"skipLibCheck": true, /* Skip type checking of declaration files. */ | |||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ | |||
}, | |||
"include": [ | |||
"src" | |||
] | |||
} |
@ -1,12 +0,0 @@ | |||
{ | |||
"extends": "./tsconfig.json", | |||
"compilerOptions": { | |||
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ | |||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ | |||
"outDir": "dist", /* Redirect output structure to the directory. */ | |||
"sourceRoot": "src/server" /* Specify the location where debugger should locate TypeScript files instead of source locations. */ | |||
}, | |||
"exclude": [ | |||
"src/app" | |||
] | |||
} |
@ -1,26 +0,0 @@ | |||
{ | |||
"extends": "./tsconfig.json", | |||
"compilerOptions": { | |||
"target": "esnext", | |||
"module": "esnext", | |||
"moduleResolution": "node", | |||
"jsx": "preserve", | |||
"resolveJsonModule": true, | |||
"lib": ["esnext", "dom"], | |||
"types": ["vite/client"], | |||
"sourceRoot": "src/app", | |||
"paths": { | |||
"@app/*": ["app/*"], | |||
"@common/*": ["common/*"] | |||
} | |||
}, | |||
"include": [ | |||
"src/app/**/*.ts", | |||
"src/app**/*.d.ts", | |||
"src/app/**/*.tsx", | |||
"src/app/**/*.vue" | |||
], | |||
"exclude": [ | |||
"src/server" | |||
] | |||
} |
@ -1,14 +0,0 @@ | |||
import { defineConfig } from 'vite' | |||
import vue from '@vitejs/plugin-vue' | |||
// https://vitejs.dev/config/ | |||
export default defineConfig({ | |||
plugins: [vue()], | |||
build: { | |||
manifest: true, | |||
outDir: "./dist/public" | |||
}, | |||
server: { | |||
port: 3201 | |||
} | |||
}) |