@ -0,0 +1,149 @@ | |||
<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 "../auth"; | |||
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> |
@ -1,20 +1,101 @@ | |||
<template> | |||
<div class="mx-auto my-auto flex flex-col"> | |||
<span class="text-2xl">Welcome, {{ name }}</span> | |||
<router-link :to="{name: 'Logout'}" class="bg-red-500 text-white p-3 rounded-lg shadow-md focus:outline-none hover:bg-red-600 text-center">Sign Out</router-link> | |||
</div> | |||
<form @submit.prevent="search"> | |||
<div class="flex rounded-xl overflow-hidden shadow-md mb-8"> | |||
<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> | |||
<ul class="w-full grid gap-8 grid-cols-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 self-center "> | |||
<li class="inline-block" v-for="(movie, index) in movies" @click="activeMovie = index"> | |||
<div class="w-full h-full flex shadow-lg bg-gray-300 text-gray-500 text-4xl overflow-hidden rounded-xl cursor-pointer motion-safe:transform hover:scale-105 transition-transform ease-out"> | |||
<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" loading="lazy" :ref="`poster-${index}`" | |||
:src="`/api/tmdb/image/w185${movie.poster_path}`"> | |||
</div> | |||
<div> | |||
<h3></h3> | |||
</div> | |||
</li> | |||
</ul> | |||
<movie-modal v-if="activeMovie != -1" :movie="movies[activeMovie]" :img-elem="$refs[`poster-${activeMovie}`]"/> | |||
</template> | |||
<script lang="ts"> | |||
import { defineComponent } from "vue"; | |||
import * as auth from "../auth"; | |||
import { IApiDataResponse } from "@common/api_schema"; | |||
import { IMovieDetails, IMovieSearchResult, IPaginatedResponse } from "@lib/tmdb/schema"; | |||
import MovieModal from "../components/MovieModal.vue"; | |||
import { authFetch } from "../auth"; | |||
type MovieResults = IApiDataResponse<IPaginatedResponse<IMovieSearchResult>>; | |||
export default defineComponent({ | |||
components: {}, | |||
components: { | |||
MovieModal | |||
}, | |||
data() { | |||
return { | |||
name: auth.getUser().name.split(' ')[0] | |||
activeMovie: -1, | |||
exampleTitles: ["John Wick", "The Incredibles 2", "Stand by Me", "Shawshank Redemption", "The Dark Knight", "Pulp Fiction", "Forest Gump"], | |||
expandedIndex: -1, | |||
isSubmitting: false, | |||
movies: <IMovieSearchResult[]>[], | |||
searchValue: <string>this.$route.query["query"] || "", | |||
page: -1, | |||
totalPages: 0, | |||
totalResults: 0 | |||
} | |||
}, | |||
methods: { | |||
async search() { | |||
if (this.isSubmitting || this.searchValue.trim().length == 0) { | |||
return; | |||
} | |||
this.$router.replace({ name: "Home", query: { query: this.searchValue } }); | |||
this.isSubmitting = true; | |||
try { | |||
let response = <MovieResults> await (authFetch(`/api/movie/search?query=${encodeURI(this.searchValue)}`).then(response => response.json())); | |||
this.movies = response.data.results; | |||
this.page = response.data.page; | |||
this.totalPages = response.data.total_pages; | |||
this.totalResults = response.data.total_results; | |||
console.log("Got results", this.totalResults); | |||
} catch(e) { | |||
console.log("Error fetching movies:", e); | |||
} | |||
this.isSubmitting = false; | |||
} | |||
}, | |||
mounted() { | |||
console.log("Mounted main component"); | |||
this.search(); | |||
} | |||
}); | |||
</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> |
@ -0,0 +1,7 @@ | |||
export interface IApiResponse { | |||
status: string | |||
} | |||
export interface IApiDataResponse<T> extends IApiResponse { | |||
data: T | |||
} |
@ -1,18 +1,65 @@ | |||
import Application from "@server/Application"; | |||
import TheMovieDb from "@lib/tmdb"; | |||
import { env, secret } from "@server/util"; | |||
import { readFile } from "fs/promises"; | |||
import TVDB from "tvdb-v4"; | |||
import Service from "./Service"; | |||
import TvDb from "./TvDb"; | |||
export default class MovieSearch extends Service | |||
{ | |||
protected tmdb!: TheMovieDb; | |||
/** | |||
* The instance of TVDB | |||
*/ | |||
protected tvdb!: TvDb; | |||
public constructor(app: Application) { | |||
super("Movie Search", app); | |||
} | |||
public async boot() { | |||
/** | |||
* Start the service | |||
*/ | |||
public start() { | |||
this.tvdb = this.app.service<TvDb>("TVDB"); | |||
} | |||
/** | |||
* Boot the service | |||
*/ | |||
public async boot() { | |||
let apiKey = await secret(env("TMDB_KEY_FILE")); | |||
this.tmdb = new TheMovieDb(apiKey); | |||
} | |||
/** | |||
* Shutdown the service | |||
*/ | |||
public async shutdown() { | |||
// no-op | |||
} | |||
// Interface ----------------------------------------------------------------------------------- | |||
/** | |||
* Get the details of a movie | |||
*/ | |||
public async details(id: number) { | |||
return await this.tmdb.movie(id); | |||
} | |||
/** | |||
* Search for a movie | |||
*/ | |||
public async search(query: string, year?: number) { | |||
return await this.tmdb.searchMovie(query, year); | |||
// let results = await this.tvdb.searchMovie(query, year); | |||
// return results.map(movie => <any>{ | |||
// image : movie.image_url ? `/api/tvdb/artwork${new URL(movie.image_url).pathname}`: null, | |||
// name : movie.name, | |||
// year : movie.year | |||
// }); | |||
} | |||
} |
@ -1,23 +1,27 @@ | |||
import { FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify"; | |||
export type HandlerMethod = (request: FastifyRequest, reply: FastifyReply) => void; | |||
export type HandlerMethod = (request: FastifyRequest, reply: FastifyReply, next?: any) => void; | |||
export type MiddlewareMethod = (request: FastifyRequest, reply: FastifyReply, next: () => void) => void; | |||
export type IteratorNext = () => void; | |||
/** | |||
* A route handler that supports middleware methods | |||
*/ | |||
export function handleMiddleware(middleware: MiddlewareMethod[], handler: RouteHandlerMethod) { | |||
return <HandlerMethod> (async (request: FastifyRequest, reply: FastifyReply) => { | |||
export function handleMiddleware(middleware: MiddlewareMethod[], handler?: RouteHandlerMethod) { | |||
return <HandlerMethod> (async (request, reply, next) => { | |||
var iterator = middleware[Symbol.iterator](); | |||
var next = async () => { | |||
var nextMiddleware = async () => { | |||
let result = iterator.next(); | |||
if (result.done) { | |||
(<any>handler)(request, reply); | |||
if (handler) { | |||
(<any>handler)(request, reply); | |||
} else if (next !== undefined) { | |||
next(); | |||
} | |||
return; | |||
} | |||
result.value(request, reply, next); | |||
result.value(request, reply, nextMiddleware); | |||
}; | |||
next(); | |||
nextMiddleware(); | |||
}); | |||
} |
@ -0,0 +1,14 @@ | |||
import { constraints } from "@common/validation"; | |||
import { FastifyRequest } from "fastify"; | |||
import validate from "validate.js"; | |||
import Request from "./Request"; | |||
export default class MovieSearchRequest extends Request | |||
{ | |||
public validate(request: FastifyRequest) { | |||
return validate.async(request.query, { | |||
query: constraints.api.movie.search.query, | |||
year: constraints.api.movie.search.year | |||
},<any>{ fullMessages: false }); | |||
} | |||
} |
@ -1,11 +1,27 @@ | |||
import Application from "@server/Application"; | |||
import { FastifyInstance } from "fastify"; | |||
import MovieSearch from "@server/services/MovieSearch"; | |||
import { auth } from "../middleware/auth"; | |||
import RouteRegisterFactory from "./RouteRegisterFactory"; | |||
/** | |||
* Register API routes | |||
*/ | |||
export default function register(server: FastifyInstance, app: Application) { | |||
export default function register(factory: RouteRegisterFactory, app: Application) { | |||
server.get("/api/movie/search", async (request, reply) => { }); | |||
factory.proxy("/api/tvdb/artwork", "https://artworks.thetvdb.com/"); | |||
factory.proxy("/api/tmdb/image", "https://image.tmdb.org/t/p/"); | |||
factory.get("/api/movie/search", [auth], async (request, reply) => { | |||
let query = <string>(<any>request.query)["query"]; | |||
let year = parseInt((<any>request.query)["year"]) || undefined; | |||
let results = await app.service<MovieSearch>("Movie Search").search(query, year); | |||
reply.send({ status: "success", data: results }); | |||
}); | |||
factory.get("/api/movie/details/:id", [auth], async (request, reply) => { | |||
let id = parseInt((<any>request.params)["id"]); | |||
let results = await app.service<MovieSearch>("Movie Search").details(id); | |||
reply.send({ status: "success", data: results}); | |||
}); | |||
} | |||
@ -1,11 +1,25 @@ | |||
export function hasAllProperties(obj: any, properties: string[]) { | |||
if (Object.keys(obj).length !== properties.length) { | |||
return false; | |||
import assert from "assert"; | |||
import { readFile } from "fs/promises"; | |||
import { readFileSync } from "fs"; | |||
/** | |||
* Fetch an environment variable | |||
*/ | |||
export function env(variable: string, throwIfNotFound = true) { | |||
let value = process.env[variable]; | |||
if (throwIfNotFound) { | |||
assert(value !== undefined); | |||
} | |||
for (let key of properties) { | |||
if (!(key in obj)) { | |||
return false; | |||
} | |||
} | |||
return true; | |||
return <string>value; | |||
} | |||
/** | |||
* Fetch a secret from a file | |||
*/ | |||
export async function secret(path: string) { | |||
return (await readFile(path)).toString().trim(); | |||
} | |||
export function secretSync(path: string) { | |||
return readFileSync(path).toString().trim(); | |||
} |