[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.

add review text

+323 -17
+1 -1
src/lib/Components/ItemCard.svelte
··· 27 27 {#if item.poster_path} 28 28 <img 29 29 src="https://image.tmdb.org/t/p/w500{item.poster_path}" 30 - alt="movie poster for {item.original_title}" 30 + alt="movie poster for {item.original_title ?? item.original_name}" 31 31 class="size-full object-cover object-center lg:size-full" 32 32 /> 33 33 {/if}
+2 -2
src/lib/Components/Rating.svelte
··· 1 1 <script lang="ts"> 2 2 import { cn } from '$lib/utils'; 3 - let { rating = $bindable() }: { rating: number } = $props(); 3 + let { rating = $bindable(), size = "size-5" }: { rating: number, size?: string } = $props(); 4 4 5 5 let hoverRating = $state(rating); 6 6 ··· 14 14 {#each Array.from({ length: 5 }).map((_, i) => i + 1) as i} 15 15 <button onclick={() => (rating = i)}> 16 16 <svg 17 - class={cn('size-5 shrink-0 text-base-600 stroke-base-500', i <= hoverRating && 'text-accent-500 stroke-accent-400')} 17 + class={cn(size, 'shrink-0 text-base-600 stroke-base-500', i <= hoverRating && 'text-accent-500 stroke-accent-400')} 18 18 viewBox="0 0 24 24" 19 19 fill="currentColor" 20 20 aria-hidden="true"
+67
src/lib/Components/ReviewCard.svelte
··· 1 + <script lang="ts"> 2 + import Rating from './Rating.svelte'; 3 + import RelativeTime from './relative-time/RelativeTime.svelte'; 4 + 5 + let { 6 + review, 7 + item, 8 + user 9 + }: { 10 + review: { 11 + rating: number; 12 + ratingText?: string; 13 + updatedAt: string; 14 + movieId?: number; 15 + showId?: number; 16 + }; 17 + item: { 18 + original_title?: string; 19 + original_name?: string; 20 + kind: string; 21 + poster_path: string; 22 + }; 23 + user: { 24 + displayName?: string; 25 + handle: string; 26 + avatar: string; 27 + }; 28 + } = $props(); 29 + </script> 30 + 31 + <a href={item.movieId ? `/movie/${item.movieId}` : `/tv/${item.showId}`} class="flex w-full max-w-2xl flex-col gap-2 rounded-xl bg-white/5 border border-white/10 p-6 backdrop-blur-sm"> 32 + <div class="flex gap-4 items-center"> 33 + <div 34 + class="relative z-20 aspect-[2/3] h-44 w-auto shrink-0 overflow-hidden rounded-md border border-base-800 bg-base-900/50 transition-opacity duration-75 group-hover:opacity-75" 35 + > 36 + {#if item.poster_path} 37 + <img 38 + src="https://image.tmdb.org/t/p/w500{item.poster_path}" 39 + alt="movie poster for {item.original_title ?? item.original_name}" 40 + class="size-full object-cover object-center lg:size-full" 41 + /> 42 + {/if} 43 + </div> 44 + <div class="flex w-full flex-col justify-center gap-4"> 45 + <div class="flex w-full flex-row sm:items-center gap-4"> 46 + <div class="flex items-center gap-2"> 47 + {#if user.avatar} 48 + <img src={user.avatar} alt="user avatar" class="size-5 rounded-full" /> 49 + {/if} 50 + <div class="text-md font-semibold">{user.displayName ?? user.handle}</div> 51 + </div> 52 + <div class="text-sm text-base-500"> 53 + <RelativeTime date={new Date(review.updatedAt)} locale="en-US" /> 54 + </div> 55 + </div> 56 + 57 + <div class="flex w-full flex-col gap-4"> 58 + <div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-4"> 59 + <div class="text-2xl font-bold">{item.original_title ?? item.original_name}</div> 60 + <Rating rating={review.rating} size="size-6" /> 61 + </div> 62 + 63 + <div class="text-sm text-base-300">{@html review.ratingText?.replace('\n', '<br /><br />')}</div> 64 + </div> 65 + </div> 66 + </div> 67 + </a>
+17
src/lib/Components/relative-time/RelativeTime.svelte
··· 1 + <script lang="ts"> 2 + import { onDestroy } from 'svelte'; 3 + import { register, unregister } from './state'; 4 + 5 + export let date: Date | number; 6 + export let locale: string; 7 + export let live = true; 8 + 9 + let instance = new Object(); 10 + let text = ''; 11 + 12 + register(instance, date, locale, live, (value) => ({ text } = value)); 13 + 14 + onDestroy(() => unregister(instance)); 15 + </script> 16 + 17 + <span class={$$props.class}>{text}</span>
+31
src/lib/Components/relative-time/action.ts
··· 1 + import type { Callback } from './render'; 2 + import { register, unregister } from './state'; 3 + 4 + export interface Options { 5 + date: Date | number; 6 + locale?: string; 7 + live?: boolean; 8 + } 9 + 10 + export function relativeTime(node: HTMLElement, options: Options) { 11 + const callback: Callback = ({ text }) => (node.textContent = text); 12 + 13 + function init(options: Options) { 14 + const date = options.date; 15 + const locale = options.locale || navigator.language; 16 + const live = (options.live = true); 17 + 18 + register(node, date, locale, live, callback); 19 + } 20 + 21 + init(options); 22 + 23 + return { 24 + update(options: Options) { 25 + init(options); 26 + }, 27 + destroy() { 28 + unregister(node); 29 + } 30 + }; 31 + }
+12
src/lib/Components/relative-time/formatter.ts
··· 1 + // keep a cache of formatter per locale, to avoid re-creating them (GC) 2 + const formatters = new Map<string, Intl.RelativeTimeFormat>(); 3 + 4 + // get the Intl.RelativeTimeFormat formatter for the given locale 5 + export function getFormatter(locale: string) { 6 + if (formatters.has(locale)) { 7 + return formatters.get(locale)!; 8 + } 9 + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'always', style: 'narrow' }); 10 + formatters.set(locale, formatter); 11 + return formatter; 12 + }
+4
src/lib/Components/relative-time/index.ts
··· 1 + export * from './action'; 2 + export type { Callback } from './render'; 3 + export { register, unregister } from './state'; 4 + export { default as default } from './RelativeTime.svelte';
+80
src/lib/Components/relative-time/render.ts
··· 1 + export type Callback = (result: { 2 + seconds: number; 3 + count: number; 4 + units: Intl.RelativeTimeFormatUnit; 5 + text: string; 6 + }) => void; 7 + 8 + export interface RenderState { 9 + date: Date | number; 10 + callback: Callback; 11 + formatter: Intl.RelativeTimeFormat; 12 + } 13 + 14 + // Array reprsenting one minute, hour, day, week, month, etc in seconds 15 + const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity]; 16 + 17 + // Array equivalent to the above but in the string representation of the units 18 + const formatUnits: Intl.RelativeTimeFormatUnit[] = [ 19 + 'seconds', 20 + 'minutes', 21 + 'hours', 22 + 'days', 23 + 'weeks', 24 + 'months', 25 + 'years' 26 + ]; 27 + 28 + // function to render relative time into 29 + export function render(state: RenderState, now: number) { 30 + const { date, callback, formatter } = state; 31 + 32 + // Allow dates or times to be passed 33 + const timeMs = typeof date === 'number' ? date : date.getTime(); 34 + 35 + // Get the amount of seconds between the given date and now 36 + const delta = timeMs - now; 37 + const seconds = Math.round(delta / 1000); 38 + 39 + // Grab the ideal cutoff unit 40 + const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(seconds)); 41 + 42 + // units 43 + const units = formatUnits[unitIndex]; 44 + 45 + // Get the divisor to divide from the seconds. E.g. if our unit is 'day' our divisor 46 + // is one day in seconds, so we can divide our seconds by this to get the # of days 47 + const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1; 48 + 49 + // count of units 50 + const count = Math.round(seconds / divisor); 51 + 52 + // Intl.RelativeTimeFormat do its magic 53 + callback({ 54 + seconds: seconds, 55 + count, 56 + units, 57 + text: formatter.format(count, units) 58 + }); 59 + 60 + // calculate time to next update, taking account rounding (e.g. it goes from 2 minutes to 1 minute at 90 seconds) 61 + // and also the changeover from units (59 seconds shouldn't show as 1 minute, so at 61 seconds we schedule the next 62 + // update for 1 second time) 63 + 64 + const divisorMs = divisor * 1000; 65 + 66 + let updateIn; 67 + 68 + if (unitIndex) { 69 + updateIn = divisorMs / 2 - (Math.abs(delta) % divisorMs); 70 + if (updateIn < 0) { 71 + updateIn += divisorMs; 72 + } 73 + } else { 74 + updateIn = divisorMs - (Math.abs(delta) % divisorMs); 75 + } 76 + 77 + const updateAt = now + updateIn; 78 + 79 + return updateAt; 80 + }
+60
src/lib/Components/relative-time/state.ts
··· 1 + import { getFormatter } from './formatter'; 2 + import { render } from './render'; 3 + import type { Callback, RenderState } from './render'; 4 + 5 + interface UpdateState extends RenderState { 6 + update: number; 7 + } 8 + 9 + // keep track of each instance 10 + const instances = new Map<Object, UpdateState>(); 11 + 12 + // we use a single timer for efficiency and to keep updates in sync 13 + let updateInterval: number | NodeJS.Timeout; 14 + 15 + // register or update instance 16 + export function register( 17 + instance: Object, 18 + date: Date | number, 19 + locale: string, 20 + live: boolean, 21 + callback: Callback 22 + ) { 23 + // get the formatter for the given locale, we do this here so we don't keep having to look it up on each tick 24 + const formatter = getFormatter(locale); 25 + 26 + // create state to render 27 + const state = { date, callback, formatter }; 28 + 29 + // initial render is immediate, so works for SSR 30 + const update = render(state, Date.now()); 31 + 32 + // if it's to update live, we keep a track and schedule the next update 33 + if (live) { 34 + instances.set(instance, { ...state, update }); 35 + } else { 36 + instances.delete(instance); 37 + } 38 + 39 + // start the clock ticking if there are any live instances 40 + if (instances.size) { 41 + updateInterval = 42 + updateInterval || 43 + setInterval(() => { 44 + const now = Date.now(); 45 + for (const state of instances.values()) { 46 + if (state.update <= now) { 47 + state.update = render(state, now); 48 + } 49 + } 50 + }, 1000); 51 + } 52 + } 53 + 54 + export function unregister(instance: Object) { 55 + instances.delete(instance); 56 + if (instances.size === 0) { 57 + clearInterval(updateInterval); 58 + updateInterval = 0; 59 + } 60 + }
+21 -3
src/routes/+layout.svelte
··· 12 12 13 13 import { onNavigate } from '$app/navigation'; 14 14 import { onMount } from 'svelte'; 15 + import { navigating } from '$app/stores'; 16 + import { slide } from 'svelte/transition'; 17 + import { expoOut } from 'svelte/easing'; 15 18 16 19 let { children, data } = $props(); 17 20 ··· 30 33 try { 31 34 const cachedRatedItems = localStorage.getItem(`ratedItems-${data.user.did}`); 32 35 const lastUpdate = localStorage.getItem(`ratedItems-${data.user.did}-lastUpdate`); 33 - if (cachedRatedItems && lastUpdate && new Date(lastUpdate).getTime() + 10 * 60 * 1000 > Date.now()) { 34 - watchedItems.ratedMovies = new Map(JSON.parse(cachedRatedItems).movies.map((movie) => [movie.id, movie])); 35 - watchedItems.ratedShows = new Map(JSON.parse(cachedRatedItems).shows.map((show) => [show.id, show])); 36 + if ( 37 + cachedRatedItems && 38 + lastUpdate && 39 + new Date(lastUpdate).getTime() + 10 * 60 * 1000 > Date.now() 40 + ) { 41 + watchedItems.ratedMovies = new Map( 42 + JSON.parse(cachedRatedItems).movies.map((movie) => [movie.id, movie]) 43 + ); 44 + watchedItems.ratedShows = new Map( 45 + JSON.parse(cachedRatedItems).shows.map((show) => [show.id, show]) 46 + ); 36 47 37 48 console.log(watchedItems.ratedMovies); 38 49 console.log(watchedItems.ratedShows); ··· 80 91 } 81 92 }); 82 93 </script> 94 + 95 + {#if $navigating} 96 + <div 97 + class="fixed left-0 right-0 top-0 z-50 h-0.5 bg-accent-500" 98 + in:slide={{ delay: 200, duration: 10000, axis: 'x', easing: expoOut }} 99 + ></div> 100 + {/if} 83 101 84 102 <div class="min-h-screen bg-base-950"> 85 103 {@render children()}
+18 -9
src/routes/[kind]/[id]/+page.svelte
··· 6 6 import Container from '$lib/Components/Container.svelte'; 7 7 import ItemsList from '$lib/Components/ItemsList.svelte'; 8 8 import Rating from '$lib/Components/Rating.svelte'; 9 + import ReviewCard from '$lib/Components/ReviewCard.svelte'; 9 10 10 11 let { data }: { data: PageData } = $props(); 11 12 </script> ··· 30 31 </div> 31 32 32 33 {#if data.watchProviders.DE?.flatrate} 33 - <div class="mt-2 sm:mt-4 text-sm text-white"> 34 + <div class="mt-2 text-sm text-white sm:mt-4"> 34 35 <div class="mb-2 flex flex-wrap gap-4 text-xs font-medium"> 35 36 stream on 36 - <span class="text-base-400" 37 - >(powered by <a 37 + <span class="flex items-center gap-2 text-base-400" 38 + >from <a 38 39 href="https://www.justwatch.com" 39 40 target="_blank" 40 - class="text-base-300 hover:text-accent-300">justwatch</a 41 - >)</span 41 + class="text-base-300 hover:text-accent-300" 42 + > 43 + <img src="/justwatch_logo.svg" alt="justwatch" class="h-3" /> 44 + </a></span 42 45 > 43 46 </div> 44 47 <a href={data.watchProviders.DE.link} target="_blank" class="flex flex-wrap gap-2"> ··· 55 58 </div> 56 59 </div> 57 60 58 - <div class="py-8 px-4 text-sm text-white"> 61 + <div class="px-4 py-8 text-sm text-white"> 59 62 {#if data.user} 60 63 <div class="flex gap-4"> 61 64 {#if !watchedItems.hasRated(data.result)} ··· 82 85 rate {data.kind === 'movie' ? 'movie' : 'show'} 83 86 </button> 84 87 {:else} 85 - <div class="text-lg font-semibold flex gap-2"> 86 - your rating: <Rating rating={watchedItems.getRating(data.result)?.rating ?? 0} /> 87 - </div> 88 + <!-- <div class="flex gap-2 text-lg font-semibold"> 89 + your rating: <Rating rating={watchedItems.getRating(data.result)?.rating ?? 0} /> 90 + </div> --> 91 + 92 + <ReviewCard 93 + item={data.result} 94 + review={watchedItems.getRating(data.result)} 95 + user={data.user} 96 + /> 88 97 {/if} 89 98 </div> 90 99 {/if}
+10 -2
src/routes/user/[handle]/+page.svelte
··· 2 2 import Container from '$lib/Components/Container.svelte'; 3 3 import ItemsGrid from '$lib/Components/ItemsGrid.svelte'; 4 4 import Profile from '$lib/Components/Profile.svelte'; 5 + import ReviewCard from '$lib/Components/ReviewCard.svelte'; 5 6 import { onMount } from 'svelte'; 6 7 7 8 let { data } = $props(); ··· 14 15 original_name?: string; 15 16 rating?: number; 16 17 ratingText?: string; 18 + updatedAt: string; 17 19 }[] = $state([]); 18 20 19 21 let loading = $state(true); ··· 23 25 const itemsData = await response.json(); 24 26 items = itemsData.items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); 25 27 loading = false; 28 + console.log(items); 26 29 }); 27 30 </script> 28 31 ··· 30 33 <Profile profile={data.profile} /> 31 34 32 35 {#if items.length > 0} 33 - <ItemsGrid 36 + <!-- <ItemsGrid 34 37 class="px-4 py-8" 35 38 items={items.map((movie) => ({ 36 39 poster_path: movie.poster_path ?? '', ··· 40 43 rating: movie.rating ?? undefined, 41 44 ratingText: movie.ratingText ?? undefined 42 45 }))} 43 - /> 46 + /> --> 47 + <div class="flex flex-col gap-8 w-full items-center py-8 px-4"> 48 + {#each items as item} 49 + <ReviewCard item={item} user={data.profile} review={item} /> 50 + {/each} 51 + </div> 44 52 {:else if loading} 45 53 <p class="text-center text-base-500 py-8">Loading reviews...</p> 46 54 {:else}