[READ-ONLY] Mirror of https://github.com/flo-bit/skywatched. review movies and tv shows, based on at proto skywatched.app
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #24 from flo-bit/v2

v2

authored by

Florian and committed by
GitHub
(Dec 8, 2024, 6:26 PM +0100) 634715b4 9208ca92

+280 -72
+67
src/lib/Components/MovieCard.svelte
··· 1 + <script lang="ts"> 2 + import { enhance } from '$app/forms'; 3 + import { cn } from '$lib/utils'; 4 + const { 5 + movie, 6 + showMark, 7 + watchedMovies 8 + }: { 9 + movie: { poster_path: string; original_title: string; id: number; watched?: boolean }; 10 + showMark?: boolean; 11 + watchedMovies?: Set<number>; 12 + } = $props(); 13 + </script> 14 + 15 + <div class="group relative"> 16 + <div 17 + class="pointer-events-none relative z-20 aspect-[2/3] h-auto min-h-44 sm:min-h-64 w-full overflow-hidden rounded-md border border-base-800 bg-base-900/50 transition-opacity duration-75 group-hover:opacity-75" 18 + > 19 + {#if movie.poster_path} 20 + <img 21 + src="https://image.tmdb.org/t/p/w500{movie.poster_path}" 22 + alt="movie poster for {movie.original_title}" 23 + class="size-full object-cover object-center lg:size-full" 24 + /> 25 + {/if} 26 + 27 + {#if showMark} 28 + <form method="post" action="?/mark" use:enhance> 29 + <input type="hidden" name="id" value={movie.id} /> 30 + 31 + <button 32 + class={cn( 33 + 'pointer-events-auto absolute bottom-2 right-2 z-20 sm:hidden rounded-full border border-base-50/20 bg-black/30 p-2 text-base-50 backdrop-blur-sm group-hover:block', 34 + watchedMovies?.has(movie.id) 35 + ? 'sm:block border-green-500/20 bg-green-900/60 text-green-500' 36 + : '' 37 + )} 38 + onclick={() => { 39 + watchedMovies?.add(movie.id); 40 + }} 41 + > 42 + <span class="sr-only">mark as watched</span> 43 + <svg 44 + xmlns="http://www.w3.org/2000/svg" 45 + fill="none" 46 + viewBox="0 0 24 24" 47 + stroke-width="2.5" 48 + stroke="currentColor" 49 + class="size-5" 50 + > 51 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 52 + </svg> 53 + </button> 54 + </form> 55 + {/if} 56 + </div> 57 + <div class="mt-2 flex justify-between"> 58 + <h3 class="sm:text-md text-sm font-medium text-base-50"> 59 + <a href="/movie/{movie.id}"> 60 + <span aria-hidden="true" class="absolute inset-0"></span> 61 + <div class="line-clamp-2 max-w-full"> 62 + {movie.original_title} 63 + </div> 64 + </a> 65 + </h3> 66 + </div> 67 + </div>
+13 -29
src/lib/Components/MovieGrid.svelte
··· 1 1 <script lang="ts"> 2 2 import { cn } from '../utils'; 3 + import MovieCard from './MovieCard.svelte'; 4 + 3 5 const { 4 6 movies, 5 - class: className 6 - }: { movies: { poster_path: string; original_title: string; id: number, movieId: number }[]; class?: string } = 7 - $props(); 7 + class: className, 8 + showMark, 9 + watchedMovies 10 + }: { 11 + movies: { poster_path: string; original_title: string; id: number; movieId: number }[]; 12 + class?: string; 13 + showMark?: boolean; 14 + watchedMovies?: Set<number>; 15 + } = $props(); 8 16 </script> 9 17 10 18 <div 11 19 class={cn( 12 - 'mt-6 grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-3 lg:grid-cols-4 xl:gap-x-8', 20 + 'mt-6 grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 xl:gap-x-8', 13 21 className 14 22 )} 15 23 > 16 24 {#each movies as movie} 17 - <div class="group relative"> 18 - <div 19 - class="aspect-h-3 aspect-w-2 w-full overflow-hidden rounded-md border border-base-800 bg-base-900/50 transition-opacity duration-75 group-hover:opacity-75" 20 - > 21 - {#if movie.poster_path} 22 - <img 23 - src="https://image.tmdb.org/t/p/w500{movie.poster_path}" 24 - alt="poster" 25 - class="size-full object-cover object-center lg:size-full" 26 - /> 27 - {/if} 28 - </div> 29 - <div class="mt-2 flex justify-between"> 30 - <div> 31 - <h3 class="sm:text-md text-sm font-medium text-base-50"> 32 - <a href="/movie/{movie.movieId}"> 33 - <span aria-hidden="true" class="absolute inset-0"></span> 34 - <div class="line-clamp-2 max-w-full"> 35 - {movie.original_title} 36 - </div> 37 - </a> 38 - </h3> 39 - </div> 40 - </div> 41 - </div> 25 + <MovieCard {movie} {showMark} {watchedMovies} /> 42 26 {/each} 43 27 </div>
+27
src/lib/Components/MovieList.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '../utils'; 3 + import MovieCard from './MovieCard.svelte'; 4 + 5 + const { 6 + movies, 7 + class: className, 8 + showMark, 9 + watchedMovies 10 + }: { 11 + movies: { poster_path: string; original_title: string; id: number }[]; 12 + class?: string; 13 + showMark?: boolean; 14 + watchedMovies?: Set<number>; 15 + } = $props(); 16 + </script> 17 + 18 + <div 19 + class={cn( 20 + 'flex gap-x-6 gap-y-10 overflow-x-auto', 21 + className 22 + )} 23 + > 24 + {#each movies as movie} 25 + <MovieCard {movie} {showMark} {watchedMovies} /> 26 + {/each} 27 + </div>
+28
src/lib/Components/Rating.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '$lib/utils'; 3 + const { rating = 0 }: { rating: number } = $props(); 4 + 5 + let hoverRating = $state(rating); 6 + </script> 7 + 8 + <div class="flex items-center xl:col-span-1"> 9 + <div class="flex items-center"> 10 + {#each Array.from({ length: 5 }).map((_, i) => i + 1) as i} 11 + <button> 12 + <svg 13 + class={cn('size-5 shrink-0 text-base-200/20 stroke-base-50/20', i <= hoverRating && 'text-accent-500 stroke-accent-400')} 14 + viewBox="0 0 24 24" 15 + fill="currentColor" 16 + aria-hidden="true" 17 + data-slot="icon" 18 + onmouseenter={() => (hoverRating = i)} 19 + onmouseleave={() => (hoverRating = rating)} 20 + > 21 + <path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" /> 22 + </svg> 23 + 24 + <span class="sr-only">rate {i} stars</span> 25 + </button> 26 + {/each} 27 + </div> 28 + </div>
+9 -4
src/lib/Components/VideoPlayer.svelte
··· 5 5 6 6 const { id, class: className }: { id: string; class?: string } = $props(); 7 7 8 - onMount(async () => { 8 + 9 + $effect(() => { 10 + console.log('id', id); 11 + 9 12 const player = new Plyr('.js-player', { 10 13 settings: ['captions', 'quality', 'loop', 'controls'] 11 14 }); 12 15 }); 13 16 </script> 14 17 15 - <div 16 - class={cn( 18 + {#key id} 19 + <div 20 + class={cn( 17 21 'aspect-video relative w-full max-w-2xl overflow-hidden rounded-xl border border-black bg-white object-cover dark:border-white/10 dark:bg-white/5', 18 22 className 19 23 )} ··· 28 32 data-plyr-provider="youtube" 29 33 data-plyr-embed-id={id} 30 34 ></div> 35 + </div> 31 36 </div> 32 - </div> 37 + {/key} 33 38 34 39 <style> 35 40 * {
+23
src/lib/server/movies.ts
··· 1 1 import { env } from '$env/dynamic/private'; 2 + import { db } from './db'; 3 + import * as table from '$lib/server/db/schema'; 4 + import { eq, and } from 'drizzle-orm'; 2 5 3 6 export async function searchMovie(query: string) { 4 7 const apiUrl = `https://api.themoviedb.org/3/search/movie?query=${query}&include_adult=false&language=en-US&page=1`; ··· 75 78 76 79 return data.results; 77 80 } 81 + 82 + export async function checkWatched(id: number, username: string): Promise<boolean | number> { 83 + const movie = await db 84 + .select() 85 + .from(table.movies) 86 + .where(and(eq(table.movies.movieId, id), eq(table.movies.username, username))); 87 + 88 + if (movie.length > 0) return movie[0].watched; 89 + 90 + return false; 91 + } 92 + 93 + export async function getWatchedMoviesIds(username: string) { 94 + const movies = await db 95 + .select({ id: table.movies.movieId }) 96 + .from(table.movies) 97 + .where(and(eq(table.movies.username, username), eq(table.movies.watched, 1))); 98 + 99 + return new Set(movies.map((movie) => movie.id)); 100 + }
+8 -1
src/routes/+layout.server.ts
··· 1 + import { getWatchedMoviesIds } from '$lib/server/movies'; 1 2 import type { LayoutServerLoad } from './$types'; 2 3 3 4 export const load: LayoutServerLoad = async (event) => { 4 - return { user: event.locals.user }; 5 + if (event.locals.user) { 6 + const watchedMovies = await getWatchedMoviesIds(event.locals.user.username); 7 + 8 + return { user: event.locals.user, watchedMovies }; 9 + } 10 + 11 + return {}; 5 12 };
+20 -3
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import Logo from '$lib/Components/Logo.svelte'; 3 3 import '../app.css'; 4 - let { children } = $props(); 5 - // deploy 4 + let { children, data } = $props(); 6 5 </script> 7 6 8 7 <header class="absolute inset-x-0 top-0 z-50"> ··· 14 13 <Logo class="size-8" /> 15 14 </a> 16 15 </div> 17 - <div class="flex flex-1 justify-end"> 16 + <div class="flex flex-1 justify-end gap-4"> 17 + {#if data.user} 18 + <a 19 + href="/search" 20 + class="text-sm/6 font-semibold text-white transition-colors duration-75 hover:text-accent-400" 21 + > 22 + <span class="sr-only">add movie</span> 23 + <svg 24 + xmlns="http://www.w3.org/2000/svg" 25 + fill="none" 26 + viewBox="0 0 24 24" 27 + stroke-width="1.5" 28 + stroke="currentColor" 29 + class="size-6" 30 + > 31 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 32 + </svg> 33 + </a> 34 + {/if} 18 35 <a 19 36 href="/login" 20 37 class="text-sm/6 font-semibold text-white transition-colors duration-75 hover:text-accent-400"
+1 -1
src/routes/+page.svelte
··· 19 19 nyx 20 20 </h1> 21 21 <p class="text-md mt-8 text-pretty font-medium text-base-400 sm:text-lg"> 22 - linktree but for movies. work in progress. 22 + social media but for movies. work in progress. 23 23 </p> 24 24 </div> 25 25
+6
src/routes/login/+page.server.ts
··· 74 74 parallelism: 1 75 75 }); 76 76 77 + // check if username is already taken 78 + const results = await db.select().from(table.user).where(eq(table.user.username, username)); 79 + if (results.length > 0) { 80 + return fail(400, { message: 'username already taken' }); 81 + } 82 + 77 83 try { 78 84 await db.insert(table.user).values({ id: userId, username, passwordHash }); 79 85
+9 -16
src/routes/movie/[id]/+page.server.ts
··· 1 1 import { db } from '$lib/server/db'; 2 - import { getDetails, getRecommendations, getTrailer } from '$lib/server/movies'; 2 + import { checkWatched, getDetails, getRecommendations, getTrailer } from '$lib/server/movies'; 3 3 import type { Actions } from './$types'; 4 4 import * as table from '$lib/server/db/schema'; 5 5 import { eq, and } from 'drizzle-orm'; ··· 16 16 return error(404, 'Not found'); 17 17 } 18 18 19 + const trailer = await getTrailer(id); 20 + 21 + const recommendations = await getRecommendations(id); 22 + 19 23 let watched = false; 20 24 21 25 if (event.locals.user?.username) { 22 - // check if user has watched this movie 23 - const movie = await db 24 - .select() 25 - .from(table.movies) 26 - .where( 27 - and(eq(table.movies.movieId, id), eq(table.movies.username, event.locals.user?.username)) 28 - ); 29 - if (movie.length > 0 && movie[0].watched !== 0) { 30 - watched = true; 31 - } 26 + watched = (await checkWatched(id, event.locals.user?.username)) as boolean; 32 27 } 33 - const trailer = await getTrailer(id); 34 - 35 - const recommendations = await getRecommendations(id); 36 28 37 29 return { result, watched, trailer, recommendations }; 38 30 } ··· 44 36 export const actions: Actions = { 45 37 mark: async (event) => { 46 38 const username = event.locals.user?.username as string; 47 - const movieId = parseInt(event.params.id); 39 + const movieId = parseInt((await event.request.formData()).get('id') as string); 48 40 49 41 const result = await getDetails(movieId); 50 42 ··· 53 45 .select() 54 46 .from(table.movies) 55 47 .where(and(eq(table.movies.movieId, movieId), eq(table.movies.username, username))); 48 + const watched = await checkWatched(movieId, username); 56 49 57 - if (movie.length > 0) { 50 + if (watched !== false) { 58 51 await db 59 52 .update(table.movies) 60 53 .set({ watched: movie[0].watched !== 0 ? 0 : 1 })
+24 -12
src/routes/movie/[id]/+page.svelte
··· 4 4 import Container from '$lib/Components/Container.svelte'; 5 5 import VideoPlayer from '$lib/Components/VideoPlayer.svelte'; 6 6 import { cn } from '$lib/utils'; 7 + import MovieList from '$lib/Components/MovieList.svelte'; 8 + import Rating from '$lib/Components/Rating.svelte'; 7 9 8 10 let { data }: { data: PageData } = $props(); 9 11 ··· 13 15 </script> 14 16 15 17 <img 16 - src="https://image.tmdb.org/t/p/w1280{data.result.backdrop_path}" 18 + src="https://image.tmdb.org/t/p/w780{data.result.backdrop_path}" 17 19 alt="" 18 20 class="fixed -z-20 h-full w-full object-cover object-center opacity-20" 19 21 /> ··· 31 33 {data.result.original_title} 32 34 </div> 33 35 {#if data.user} 34 - <div class="flex gap-2"> 36 + <div class="flex gap-4"> 35 37 <form method="post" action="?/mark" use:enhance> 38 + <input type="hidden" name="id" value={data.result.id} /> 39 + 36 40 <button 37 41 onmouseenter={() => (hoveringWatchButton = true)} 38 42 onmouseleave={() => (hoveringWatchButton = false)} ··· 42 46 )} 43 47 > 44 48 {#if data.watched} 45 - <svg 46 - xmlns="http://www.w3.org/2000/svg" 47 - fill="none" 48 - viewBox="0 0 24 24" 49 - stroke-width="2.5" 50 - stroke="currentColor" 51 - class="size-5" 52 - > 53 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 54 - </svg> 49 + <svg 50 + xmlns="http://www.w3.org/2000/svg" 51 + fill="none" 52 + viewBox="0 0 24 24" 53 + stroke-width="2.5" 54 + stroke="currentColor" 55 + class="size-5" 56 + > 57 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 58 + </svg> 55 59 watched 56 60 {:else} 57 61 mark watched 58 62 {/if} 59 63 </button> 60 64 </form> 65 + 66 + <Rating rating={Math.round(data.result.vote_average/2)} /> 61 67 </div> 62 68 {/if} 63 69 </div> ··· 72 78 <div class="mb-2 text-lg font-semibold">trailer</div> 73 79 74 80 <VideoPlayer id={data.trailer} /> 81 + {/if} 82 + 83 + {#if data.recommendations.length > 0} 84 + <div class="mb-2 mt-8 text-lg font-semibold">recommendations</div> 85 + 86 + <MovieList movies={data.recommendations} showMark={!!data.user} watchedMovies={data.watchedMovies} /> 75 87 {/if} 76 88 </Container>
+43 -4
src/routes/search/+page.server.ts
··· 1 - import { searchMovie } from '$lib/server/movies'; 1 + import { db } from '$lib/server/db'; 2 + import { checkWatched, getDetails, searchMovie } from '$lib/server/movies'; 3 + import type { Actions } from './$types'; 4 + import * as table from '$lib/server/db/schema'; 5 + import { and, eq } from 'drizzle-orm'; 6 + import { redirect } from '@sveltejs/kit'; 2 7 3 8 /** @type {import('./$types').PageServerLoad} */ 4 - export async function load({ url }) { 5 - // get search params 6 - const query = url.searchParams.get('query'); 9 + export async function load(event) { 10 + // redirect to / if not logged in 11 + if (!event.locals.user) { 12 + return redirect(302, '/login'); 13 + } 14 + 15 + const query = event.url.searchParams.get('query'); 7 16 8 17 if (query) { 9 18 const results = await searchMovie(query); ··· 13 22 14 23 return { results: [], query }; 15 24 } 25 + 26 + export const actions: Actions = { 27 + mark: async (event) => { 28 + const username = event.locals.user?.username as string; 29 + const movieId = parseInt((await event.request.formData()).get('id') as string); 30 + 31 + const result = await getDetails(movieId); 32 + 33 + const watched = await checkWatched(movieId, username); 34 + 35 + console.log(watched, username, movieId, result); 36 + 37 + if (watched !== false) { 38 + await db 39 + .update(table.movies) 40 + .set({ watched: watched ? 0 : 1 }) 41 + .where(and(eq(table.movies.movieId, movieId), eq(table.movies.username, username))); 42 + } else { 43 + await db.insert(table.movies).values({ 44 + username, 45 + id: crypto.randomUUID(), 46 + movieId: movieId, 47 + watched: 1, 48 + originalTitle: result.original_title, 49 + posterPath: result.poster_path, 50 + timestamp: new Date() 51 + }); 52 + } 53 + } 54 + };
+2 -2
src/routes/search/+page.svelte
··· 8 8 9 9 <Container> 10 10 <div> 11 - <h1 class="my-8 text-4xl font-bold tracking-tight text-base-50">search for a movie</h1> 11 + <h1 class="my-8 text-4xl font-bold tracking-tight text-base-50">add a movie</h1> 12 12 13 13 <form class="relative mt-6 flex items-center" method="GET"> 14 14 <input ··· 29 29 {#if data.results.length > 0} 30 30 <h2 class="mt-8 text-2xl font-bold tracking-tight text-base-50">results</h2> 31 31 32 - <MovieGrid movies={data.results} /> 32 + <MovieGrid movies={data.results} showMark={!!data.user} watchedMovies={data.watchedMovies} /> 33 33 {:else if data.query} 34 34 <p class="text-md mt-8 text-base-50">no results found</p> 35 35 {/if}