@ -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,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> |
@ -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> |
@ -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,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, | |||
}; |