Browse Source

Renamed home page to Search. Added nested routes for movie modal. Cleaned up movie modal with transitions/animations. Fixed other bugs

master
David Ludwig 4 years ago
parent
commit
3ffef38177
14 changed files with 389 additions and 193 deletions
  1. +7
    -1
      src/app/App.vue
  2. +46
    -0
      src/app/components/AppModals.vue
  3. +1
    -7
      src/app/components/CheckBox.vue
  4. +0
    -149
      src/app/components/MovieModal.vue
  5. +193
    -0
      src/app/components/modals/MovieModal.vue
  6. +22
    -0
      src/app/components/modals/index.ts
  7. +1
    -0
      src/app/index.ts
  8. +21
    -2
      src/app/routes/index.ts
  9. +5
    -5
      src/app/store/actions.ts
  10. +10
    -0
      src/app/store/index.ts
  11. +32
    -9
      src/app/store/mutations.ts
  12. +8
    -2
      src/app/store/state.ts
  13. +19
    -0
      src/app/styles/index.css
  14. +24
    -18
      src/app/views/Search.vue

+ 7
- 1
src/app/App.vue View File

@ -1,14 +1,20 @@
<template>
<div class="min-h-full p-4 flex flex-col">
<router-view></router-view>
<router-view/>
</div>
<!-- <app-modal/> -->
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { Action, useStore } from "./store";
import AppModal from "./components/AppModals.vue";
export default defineComponent({
name: "App",
components: {
AppModal
},
setup() {
const store = useStore();
store.dispatch(Action.AuthLoad, undefined);


+ 46
- 0
src/app/components/AppModals.vue View File

@ -0,0 +1,46 @@
<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
- 7
src/app/components/CheckBox.vue View File

@ -1,6 +1,6 @@
<template>
<label class="checkbox-label relative flex items-center">
<input type="checkbox" v-model="isChecked" :disabled="disabled">
<input class="hidden" type="checkbox" v-model="isChecked" :disabled="disabled">
<span></span>
<span v-if="label" class="ml-2">{{label}}</span>
</label>
@ -40,12 +40,6 @@ export default defineComponent({
<style>
.checkbox-label input[type="checkbox"] {
display: none;
width: 0;
height: 0;
}
.checkbox-label input[type="checkbox"]:focus-visible + span {
@apply ring ring-offset-2 ring-indigo-500;
}


+ 0
- 149
src/app/components/MovieModal.vue View File

@ -1,149 +0,0 @@
<template>
<div class="modal p-8 overflow-scroll items-center">
<div class="w-full max-w-6xl bg-white overflow-hidden rounded-xl flex-col flex-shrink-0 shadow-xl">
<div class="bg-center bg-cover" :class="{ 'text-white': isDark }"
:style="`background-image: url('/api/tmdb/image/w1280${movie.backdrop_path}')`">
<div :style="backdropOverlayStyle" class="flex">
<div class="flex p-4">
<div class="w-24 h-40 flex-shrink-0 md:h-auto md:w-72 rounded-lg md:rounded-xl overflow-hidden shadow-md mdshadow-xl">
<i v-if="!movie.poster_path" class="fas fa-image mx-auto my-auto"></i>
<img v-else class="w-full h-full object-cover"
:src="`/api/tmdb/image/w342${movie.poster_path}`">
</div>
<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"> ({{ releaseYear }})</span>
</h2>
<div class="flex font-light dot-separated">
<span>{{ releaseDate }}</span>
<span>{{ runtime }}</span>
</div>
<div v-if="movieDetails?.overview" class="mt-4 hidden md:block">
<p class="">{{movieDetails?.overview}}</p>
</div>
<div class="mt-4 hidden md:block">
<button class="py-2 px-8 rounded-full" :class="{ 'bg-white text-black': isDark, 'bg-black text-white': !isDark }">Request</button>
</div>
</div>
</div>
</div>
</div>
<div class="p-4 space-y-4 md:hidden">
<div v-if="movieDetails?.overview">
<h3 class="font-bold">Overview</h3>
<p>{{ movieDetails?.overview }}</p>
</div>
<div class="text-center">
<button class="py-2 px-8 rounded-full bg-red-500 text-white">Request</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { IApiDataResponse } from "@common/api_schema";
import { IMovieDetails } from "@lib/tmdb/schema";
import { authFetch } from "../routes";
import { getAverageRgb } from "../util";
type MovieDetails = IApiDataResponse<IMovieDetails>;
export default defineComponent({
computed: {
averageRgb(): { r: number, g: number, b: number } {
let rgb = getAverageRgb(this.imgElem);
// 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;
}
return rgb;
},
backdropOverlayStyle(): string {
let { r, g, b } = this.averageRgb;
return `background: linear-gradient(to right, rgba(${r}, ${g}, ${b}, 1.0), rgba(${r}, ${g}, ${b}, 0.84)`;
},
isDark(): boolean {
let { r, g, b } = this.averageRgb;
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.movieDetails || !this.movieDetails.release_date) {
return "";
}
return this.movieDetails.release_date.slice(0, 4);
},
releaseDate(): string {
if (!this.movieDetails || !this.movieDetails.release_date) {
return "";
}
let [year, month, day] = this.movieDetails.release_date.split('-');
return `${month}/${day}/${year}`;
},
runtime(): string {
if (!this.movieDetails || !this.movieDetails.runtime) {
return "";
}
let hours = Math.floor(this.movieDetails.runtime / 60);
let minutes = Math.floor(this.movieDetails.runtime % 60);
return (hours > 0 ? `${hours}h ` : "") + `${minutes}m`;
}
},
data() {
return {
movieDetails: <IMovieDetails|undefined>undefined
}
},
methods: {
async displayMovie() {
let response = <MovieDetails> await (authFetch(`/api/movie/details/${this.movie.id}`).then(response => response.json()));
this.movieDetails = response.data;
console.log(response);
}
},
mounted() {
this.displayMovie();
},
props: {
imgElem: {
type: HTMLImageElement,
required: true
},
movie: {
type: Object,
required: true
}
}
});
</script>
<style>
.modal {
@apply fixed inset-0 flex flex-col;
background: rgba(0, 0, 0, 0.5);
}
.dot-separated > *:not(:first-child)::before {
@apply px-2;
content: '\2022'
}
</style>

+ 193
- 0
src/app/components/modals/MovieModal.vue View File

@ -0,0 +1,193 @@
<template>
<transition appear name="fade">
<div class="modal p-8 overflow-auto items-center" @click.self="close">
<transition name="slide">
<div v-if="movie !== undefined" class="relative w-full max-w-6xl bg-white overflow-hidden rounded-xl flex-col flex-shrink-0 shadow-xl">
<div class="bg-center bg-cover" :class="{ 'text-white': isDark }"
:style="movie.backdrop_path ? `background: url('/api/tmdb/image/w1280${movie.backdrop_path}')` : ''">
<div class="movie-modal-content z-10" :style="backdropOverlayStyle">
<div class="flex p-4">
<div class="w-24 h-40 flex-shrink-0 md:h-auto md:w-72 rounded-lg md:rounded-xl overflow-hidden shadow-md mdshadow-xl">
<i v-if="!movie.poster_path" class="fas fa-image mx-auto my-auto"></i>
<img v-else class="w-full h-full object-cover" ref="poster"
:src="`/api/tmdb/image/w342${movie.poster_path}`" @load="onPosterLoad">
</div>
<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">
<button class="py-2 px-8 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 }">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">
<button class="py-2 px-8 rounded-full bg-red-500 text-white">Request</button>
</div>
</div>
</div>
</transition>
</div>
</transition>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { IApiDataResponse } from "@common/api_schema";
import { IMovieDetails } from "@lib/tmdb/schema";
import { authFetch } from "../../routes";
import { getAverageRgb } from "../../util";
import { useStore, Mutation } from "../../store";
type movie = IApiDataResponse<IMovieDetails>;
export default defineComponent({
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.release_date) {
return "";
}
return this.movie.release_date.slice(0, 4);
},
releaseDate(): string {
if (!this.movie || !this.movie.release_date) {
return "";
}
let [year, month, day] = this.movie.release_date.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: <IMovieDetails|undefined>undefined,
isPosterLoaded: false
}
},
methods: {
close() {
this.movie = undefined;
this.$emit("onClose");
},
onKeyPress(event: KeyboardEvent) {
if (event.key != "Escape") {
return;
}
this.close();
},
onPosterLoad() {
this.isPosterLoaded = true;
this.computeBackdropColor();
},
computeBackdropColor() {
let rgb: {r: number, g: number, b: number};
try {
rgb = getAverageRgb(<HTMLImageElement>this.$refs["poster"]);
} 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 = rgb;
},
async fetchMovieDetails() {
let response = <movie> await (authFetch(`/api/movie/details/${this.movieId}`).then(response => response.json()));
this.movie = response.data;
}
},
mounted() {
document.addEventListener("keydown", this.onKeyPress);
this.fetchMovieDetails();
},
beforeUnmount() {
document.removeEventListener("keydown", this.onKeyPress);
},
props: {
movieId: {
type: [Number, String],
required: true
}
},
watch: {
movieId(newId: number, oldId: number) {
console.log(newId);
}
},
beforeRouteEnter() {
useStore().commit(Mutation.LockScroll, true);
},
beforeRouteLeave() {
useStore().commit(Mutation.LockScroll, false);
}
});
</script>
<style>
.modal {
@apply fixed inset-0 flex flex-col h-screen;
background: rgba(0, 0, 0, 0.5);
}
.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>

+ 22
- 0
src/app/components/modals/index.ts View File

@ -0,0 +1,22 @@
/**
* 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
- 0
src/app/index.ts View File

@ -7,6 +7,7 @@ import "./styles/index.css";
let app = createApp(App);
app.use(router);
app.use(store);
app.mount("#app");
/**


+ 21
- 2
src/app/routes/index.ts View File

@ -5,7 +5,9 @@ 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;
}
@ -53,8 +55,25 @@ const routes: RouteRecordRaw[] = [
{
path: "/",
name: "Home",
component: () => import("../views/Home.vue"),
beforeEnter: requiresAuth
component: {
beforeRouteEnter(to, from, next) {
next({ name: "Search" });
}
}
},
{
path: "/search",
name: "Search",
component: () => import("../views/Search.vue"),
beforeEnter: requiresAuth,
children: [
{
path: ":movieId",
name: "Lookup",
component: () => import("../components/modals/MovieModal.vue"),
props: true
}
]
},
{
path: "/login",


+ 5
- 5
src/app/store/actions.ts View File

@ -62,8 +62,8 @@ export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes
resolve(body.errors || {});
return;
}
commit(Mutation.LoadUser, body.token);
commit(Mutation.StoreUser, remember);
commit(Mutation.UserLoad, body.token);
commit(Mutation.UserStore, remember);
resolve(null);
})
.catch(e => {
@ -77,7 +77,7 @@ export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes
* Forget the login token and log the user out
*/
[Action.AuthForget]({commit}) {
commit(Mutation.ForgetUser, undefined);
commit(Mutation.UserForget, undefined);
},
/**
@ -89,7 +89,7 @@ export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes
return false;
}
try {
commit(Mutation.LoadUser, token);
commit(Mutation.UserLoad, token);
} catch(e) {
dispatch(Action.AuthForget, undefined);
return false;
@ -113,7 +113,7 @@ export const actions: Actions<IState, GettersTypes, MutationsTypes, ActionsTypes
});
let response = await fetch(path, options);
if (response.status === 401) {
commit(Mutation.ForgetUser, undefined);
commit(Mutation.UserForget, undefined);
router.push({ name: "Login" });
throw Error("Unauthorized");
}


+ 10
- 0
src/app/store/index.ts View File

@ -34,3 +34,13 @@ export function useStore() {
* Export the store instance by default
*/
export default store;
import {Mutation} from "./mutations";
import Modal from "@app/components/modals";
(<any>window).modalShow = (name: Modal) => {
store.commit(Mutation.ModalShow, name);
}
(<any>window).modalHide = () => {
store.commit(Mutation.ModalHide, undefined);
}

+ 32
- 9
src/app/store/mutations.ts View File

@ -1,3 +1,4 @@
import Modal from "@app/components/modals";
import jwtDecode from "jwt-decode";
import { MutationTree } from "vuex";
import { IState } from "./state";
@ -6,28 +7,50 @@ import { IState } from "./state";
* All available mutation types
*/
export enum Mutation {
ForgetUser = "FORGET_USER",
LoadUser = "LOAD_USER",
StoreUser = "STORE_USER"
LockScroll = "LOCK_SCROLL",
UserForget = "USER_FORGET",
UserLoad = "USER_LOAD",
UserStore = "USER_STORE",
}
/**
* Type declarations for each mutation
*/
export type MutationsTypes<S = IState> = {
[Mutation.ForgetUser]: (state: S) => void,
[Mutation.LoadUser] : (state: S, token: string) => boolean,
[Mutation.StoreUser] : (state: S, remember: boolean) => void,
[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.ForgetUser](state: IState) {
[Mutation.UserForget](state: IState) {
state.user = null;
sessionStorage.removeItem("jwt");
localStorage.removeItem("jwt");
@ -36,7 +59,7 @@ export const mutations: MutationsTypes & MutationTree<IState> = {
/**
* Try loading a user from the provided token
*/
[Mutation.LoadUser](state: IState, token: string) {
[Mutation.UserLoad](state: IState, token: string) {
try {
let user: any = jwtDecode(token);
state.user = {
@ -54,7 +77,7 @@ export const mutations: MutationsTypes & MutationTree<IState> = {
/**
* Store the user to remember them
*/
[Mutation.StoreUser](state: IState, remember: boolean) {
[Mutation.UserStore](state: IState, remember: boolean) {
if (state.user == null) {
sessionStorage.removeItem("jwt");
localStorage.removeItem("jwt");


+ 8
- 2
src/app/store/state.ts View File

@ -1,15 +1,21 @@
import { IUser } from "./schema";
import type Modals from "../components/modals";
import Modal from "../components/modals";
/**
* The state definition
*/
export interface IState {
user: IUser | null,
user : IUser | null,
modalName : Modal | null,
modalVisible: boolean
}
/**
* The state implementation
*/
export const state: IState = {
user: null
user: null,
modalName: null,
modalVisible: false,
};

+ 19
- 0
src/app/styles/index.css View File

@ -6,3 +6,22 @@
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%);
}

src/app/views/Home.vue → src/app/views/Search.vue View File


Loading…
Cancel
Save