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