@ -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 { IUser } from "./schema"; | ||||
import type Modals from "../components/modals"; | |||||
import Modal from "../components/modals"; | |||||
/** | /** | ||||
* The state definition | * The state definition | ||||
*/ | */ | ||||
export interface IState { | export interface IState { | ||||
user: IUser | null, | |||||
user : IUser | null, | |||||
modalName : Modal | null, | |||||
modalVisible: boolean | |||||
} | } | ||||
/** | /** | ||||
* The state implementation | * The state implementation | ||||
*/ | */ | ||||
export const state: IState = { | export const state: IState = { | ||||
user: null | |||||
user: null, | |||||
modalName: null, | |||||
modalVisible: false, | |||||
}; | }; |