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

some simple caching, user page: load ratings on client side, add analytics

+244 -111
+5
src/app.html
··· 1 1 <!doctype html> 2 2 <html lang="en" class="h-full"> 3 + <script> 4 + !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]); 5 + posthog.init('phc_1Q4q6SdTwvStnxFWbmdOIusLc5ve0u6Fk7WpsHPoAlD',{api_host:'https://eu.i.posthog.com', person_profiles: 'identified_only', persistence: 'memory'}) 6 + </script> 7 + 3 8 <head> 4 9 <meta charset="utf-8" /> 5 10 <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
+1 -1
src/lib/Components/Footer.svelte
··· 1 - <footer class="bg-black border-t border-base-800"> 1 + <footer class="bg-black border-t border-base-800 z-10 relative"> 2 2 <div class="mx-auto max-w-2xl sm:px-6 lg:max-w-4xl xl:max-w-6xl py-12 md:flex md:items-center md:justify-between lg:px-8"> 3 3 <div class="flex justify-center gap-x-6 md:order-2"> 4 4
+1 -1
src/lib/Components/LoginModal.svelte
··· 40 40 name="handle" 41 41 id="handle" 42 42 class="block w-full rounded-md border-0 py-1.5 text-base-900 shadow-sm ring-1 ring-inset ring-base-300 placeholder:text-base-400 focus:ring-2 focus:ring-inset focus:ring-accent-600 sm:text-sm/6 dark:bg-base-900 dark:text-base-100 dark:ring-base-700 dark:placeholder:text-base-600" 43 - placeholder="yourname.blsky.social" 43 + placeholder="yourname.bsky.social" 44 44 /> 45 45 </div> 46 46 </div>
+3
src/lib/Components/RateMovieModal.svelte
··· 1 1 <script lang="ts"> 2 2 import { rateMovieModal, watchedItems } from '$lib/state.svelte'; 3 + import { toast } from 'svelte-sonner'; 3 4 import Rating from './Rating.svelte'; 4 5 5 6 let rating = $state(rateMovieModal.selectedItem.currentRating ?? 0); ··· 67 68 showId: rateMovieModal.selectedItem.showId, 68 69 rating 69 70 }); 71 + 72 + toast.success('Rating saved'); 70 73 }} 71 74 type="button" 72 75 class="inline-flex w-full justify-center rounded-md border border-accent-900 bg-accent-950/80 px-3 py-2 text-sm font-semibold text-accent-300 shadow-sm hover:bg-accent-950 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600"
+1 -1
src/lib/Components/VideoPlayer.svelte
··· 53 53 ></button> 54 54 <div 55 55 class={cn( 56 - 'aspect-video relative w-full overflow-hidden rounded-xl border border-black bg-white object-cover dark:border-white/10 dark:bg-white/5', 56 + 'aspect-video max-h-screen relative w-full overflow-hidden rounded-xl border border-black bg-white object-cover dark:border-white/10 dark:bg-white/5', 57 57 className 58 58 )} 59 59 >
+71 -1
src/lib/bluesky.ts
··· 1 + import { REL_COLLECTION } from '$lib'; 1 2 import { Agent, AtpBaseClient } from '@atproto/api'; 3 + import { getDetails } from './server/movies'; 2 4 3 5 type AgentType = Agent | AtpBaseClient | null; 4 6 ··· 24 26 25 27 const { data } = await agent.app.bsky.actor.getProfile({ actor: did }); 26 28 return data; 27 - } 29 + } 30 + export async function getAllRated({ did, agent = undefined }: { did: string; agent?: AgentType }) { 31 + if (!agent) { 32 + agent = new AtpBaseClient({ service: 'https://bsky.social' }); 33 + } 34 + 35 + let cursor: string | undefined = undefined; 36 + let items = []; 37 + do { 38 + const test = await agent.com.atproto.repo.listRecords({ 39 + repo: did, 40 + collection: REL_COLLECTION, 41 + limit: 100, 42 + cursor 43 + }); 44 + items = items.concat(test.data.records); 45 + cursor = test.data.cursor; 46 + } while (cursor); 47 + 48 + return items; 49 + } 50 + 51 + export async function getRatedItemsWithDetails({ did }: { did: string }) { 52 + const agent = new AtpBaseClient({ service: 'https://bsky.social' }); 53 + 54 + const test = await agent.com.atproto.repo.listRecords({ 55 + repo: did, 56 + collection: 'my.skylights.rel' 57 + }); 58 + 59 + const items = []; 60 + const promises = []; 61 + let count = 0; 62 + for (const record of test.data.records) { 63 + if (record.value.item.ref === 'tmdb:m') { 64 + const detailsPromise = getDetails(parseInt(record.value.item.value), 'movie').then( 65 + (details) => 66 + items.push({ 67 + movieId: parseInt(record.value.item.value), 68 + ...details, 69 + rating: record.value.rating.value / 2, 70 + ratingText: record.value.note?.value, 71 + updatedAt: record.value.note?.updatedAt ?? record.value.rating.createdAt 72 + }) 73 + ); 74 + promises.push(detailsPromise); 75 + count++; 76 + } else if (record.value.item.ref === 'tmdb:s') { 77 + const detailsPromise = getDetails(parseInt(record.value.item.value), 'tv').then((details) => 78 + items.push({ 79 + showId: parseInt(record.value.item.value), 80 + ...details, 81 + rating: record.value.rating.value / 2, 82 + ratingText: record.value.note?.value, 83 + updatedAt: record.value.note?.updatedAt ?? record.value.rating.createdAt 84 + }) 85 + ); 86 + promises.push(detailsPromise); 87 + count++; 88 + } 89 + 90 + if (count > 10) { 91 + break; 92 + } 93 + } 94 + await Promise.all(promises); 95 + 96 + return items; 97 + }
-17
src/routes/+layout.server.ts
··· 1 - import { 2 - getWatchedMoviesIds, 3 - getWatchedMoviesIdsFromPDS, 4 - getWatchedShowsIds, 5 - getWatchedShowsIdsFromPDS 6 - } from '$lib/server/movies'; 7 - import { Agent } from '@atproto/api'; 8 1 import type { LayoutServerLoad } from './$types'; 9 2 10 3 export const load: LayoutServerLoad = async (event) => { 11 - if (event.locals.user && event.locals.agent && event.locals.agent instanceof Agent) { 12 - const watchedMovies = await getWatchedMoviesIdsFromPDS( 13 - event.locals.agent, 14 - event.locals.user.did 15 - ); 16 - const watchedShows = await getWatchedShowsIdsFromPDS(event.locals.agent, event.locals.user.did); 17 - 18 - return { user: event.locals.user, watchedMovies, watchedShows }; 19 - } 20 - 21 4 return { user: event.locals.user }; 22 5 };
+61 -13
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 - import Footer from '$lib/Components/Footer.svelte'; 3 - import Logo from '$lib/Components/Logo.svelte'; 4 - import { Toaster } from 'svelte-sonner'; 5 - import { showLoginModal, watchedItems } from '$lib/state.svelte'; 6 2 import '../app.css'; 7 - import RateMovieModal from '$lib/Components/RateMovieModal.svelte'; 8 3 9 - let { children, data } = $props(); 4 + import Footer from '$lib/Components/Footer.svelte'; 5 + import { toast, Toaster } from 'svelte-sonner'; 6 + import { watchedItems } from '$lib/state.svelte'; 7 + import RateMovieModal from '$lib/Components/RateMovieModal.svelte'; 10 8 11 9 import LoginModal from '$lib/Components/LoginModal.svelte'; 12 10 import Sidebar from '$lib/Components/Sidebar.svelte'; 13 11 import VideoPlayer from '$lib/Components/VideoPlayer.svelte'; 14 12 13 + import { onNavigate } from '$app/navigation'; 14 + import { onMount } from 'svelte'; 15 15 16 - console.log(data.watchedMovies); 17 - console.log(data.watchedShows); 18 - 19 - watchedItems.ratedMovies = new Map(data.watchedMovies); 20 - watchedItems.ratedShows = new Map(data.watchedShows); 21 - 22 - import { onNavigate } from '$app/navigation'; 16 + let { children, data } = $props(); 23 17 24 18 onNavigate((navigation) => { 25 19 if (!document.startViewTransition) return; ··· 30 24 await navigation.complete; 31 25 }); 32 26 }); 27 + }); 28 + 29 + function useCachedRatedItems() { 30 + try { 31 + const cachedRatedItems = localStorage.getItem(`ratedItems-${data.user.did}`); 32 + 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 + 37 + console.log(watchedItems.ratedMovies); 38 + console.log(watchedItems.ratedShows); 39 + return true; 40 + } 41 + } catch (error) { 42 + console.error('Error fetching rated items', error); 43 + } 44 + 45 + return false; 46 + } 47 + 48 + async function fetchRatedItems() { 49 + try { 50 + // if not, fetch them 51 + const response = await fetch(`/api/getAllRated?did=${data.user.did}`); 52 + const items = await response.json(); 53 + 54 + watchedItems.ratedMovies = new Map(items.movies.map((movie) => [movie.id, movie])); 55 + watchedItems.ratedShows = new Map(items.shows.map((show) => [show.id, show])); 56 + localStorage.setItem(`ratedItems-${data.user.did}`, JSON.stringify(items)); 57 + localStorage.setItem(`ratedItems-${data.user.did}-lastUpdate`, new Date().toISOString()); 58 + 59 + console.log('fetched rated items'); 60 + 61 + return true; 62 + } catch (error) { 63 + console.error('Error fetching rated items', error); 64 + return false; 65 + } 66 + } 67 + 68 + onMount(async () => { 69 + // check if user is logged in 70 + if (!data.user) return; 71 + 72 + if (useCachedRatedItems()) { 73 + console.log('using cached rated items'); 74 + } else { 75 + console.log('fetching rated items'); 76 + if (!(await fetchRatedItems())) { 77 + console.error('Error fetching rated items'); 78 + toast.error('Error getting your rated items'); 79 + } 80 + } 33 81 }); 34 82 </script> 35 83
+3 -3
src/routes/[kind]/[id]/+page.svelte
··· 13 13 <img 14 14 src="https://image.tmdb.org/t/p/w780{data.result.backdrop_path}" 15 15 alt="" 16 - class="fixed -z-20 h-full w-full object-cover object-center opacity-20" 16 + class="fixed h-full w-full object-cover object-center opacity-20" 17 17 /> 18 - <div class="fixed inset-0 -z-10 h-full w-full bg-black/70"></div> 18 + <div class="fixed inset-0 h-full w-full bg-black/50"></div> 19 19 20 - <Container> 20 + <Container class="relative z-10"> 21 21 <div class="flex gap-4 px-4 pt-8"> 22 22 <img 23 23 src="https://image.tmdb.org/t/p/w500{data.result.poster_path}"
+28
src/routes/api/getAllRated/+server.ts
··· 1 + import { error, json, type RequestHandler } from '@sveltejs/kit'; 2 + import { getAllRated } from '$lib/bluesky'; 3 + 4 + export const GET: RequestHandler = async ({ locals, request }) => { 5 + const did = new URL(request.url).searchParams.get('did'); 6 + const agent = locals.agent; 7 + 8 + if (!did) { 9 + return error(400, 'Did is required'); 10 + } 11 + 12 + const items = await getAllRated({ did, agent }); 13 + 14 + const transformedItems = items.map((item) => { 15 + return { 16 + id: parseInt(item.value.item.value ?? '0'), 17 + rating: item.value.rating.value / 2, 18 + ratingText: item.value.note?.value, 19 + updatedAt: item.value.note?.updatedAt ?? item.value.rating.createdAt, 20 + kind: item.value.item.ref === 'tmdb:m' ? 'movie' : 'tv' 21 + }; 22 + }); 23 + 24 + return json({ 25 + movies: transformedItems.filter((item) => item.kind === 'movie'), 26 + shows: transformedItems.filter((item) => item.kind === 'tv') 27 + }); 28 + };
+14
src/routes/api/getRatedWithDetails/+server.ts
··· 1 + import { error, json, type RequestHandler } from '@sveltejs/kit'; 2 + import { getRatedItemsWithDetails } from '$lib/bluesky'; 3 + 4 + export const GET: RequestHandler = async ({ request }) => { 5 + const did = new URL(request.url).searchParams.get('did'); 6 + 7 + if (!did) { 8 + return error(400, 'Did is required'); 9 + } 10 + 11 + const items = await getRatedItemsWithDetails({ did }); 12 + 13 + return json({ items }); 14 + };
+11 -14
src/routes/api/rate/+server.ts
··· 1 - import { db } from '$lib/server/db'; 2 - import { checkWatched, getDetails } from '$lib/server/movies'; 3 1 import { error, json, type RequestHandler } from '@sveltejs/kit'; 4 - import * as table from '$lib/server/db/schema'; 5 - import { eq, and } from 'drizzle-orm'; 6 2 import { AtpBaseClient } from '@atproto/api'; 7 3 import { REL_COLLECTION } from '$lib'; 8 4 import { TID } from '@atproto/common'; 9 5 10 - // Given a cursor and limit (opt) 11 - // Return a JSON of FeedViewPost 12 6 export const POST: RequestHandler = async ({ request, locals }) => { 13 7 const user = locals.user; 14 8 const agent = locals.agent; ··· 24 18 25 19 const rkey = TID.nextStr(); 26 20 27 - await agent.com.atproto.repo.putRecord({ 21 + const record = { 28 22 repo: did, 29 23 collection: REL_COLLECTION, 30 24 rkey, ··· 33 27 ref: `tmdb:${kind === 'movie' ? 'm' : 's'}`, 34 28 value: id.toString() 35 29 }, 36 - rating: { value: rating * 2, createdAt: new Date().toISOString() }, 37 - note: { 38 - value: review, 39 - createdAt: new Date().toISOString(), 40 - updatedAt: new Date().toISOString() 41 - } 30 + rating: { value: rating * 2, createdAt: new Date().toISOString() } 42 31 } 43 - }); 32 + }; 33 + if (review) { 34 + record.record.note = { 35 + value: review, 36 + createdAt: new Date().toISOString(), 37 + updatedAt: new Date().toISOString() 38 + }; 39 + } 40 + await agent.com.atproto.repo.putRecord(record); 44 41 45 42 return json({ status: 'rated' }); 46 43 };
+6 -1
src/routes/oauth/callback/+server.ts
··· 12 12 const key = decodeBase64(NYX_PASSWORD); 13 13 const encrypted = await encryptString(key, session.did); 14 14 const encoded = encodeBase64urlNoPadding(encrypted); 15 - cookies.set('sid', encoded, { path: '/', maxAge: 60 * 60, httpOnly: true, sameSite: 'lax' }); 15 + cookies.set('sid', encoded, { 16 + path: '/', 17 + maxAge: 60 * 60 * 24 * 30, 18 + httpOnly: true, 19 + sameSite: 'lax' 20 + }); 16 21 } catch (err) { 17 22 console.log(err); 18 23 error(500, { message: (err as Error).message });
+2 -48
src/routes/user/[handle]/+page.server.ts
··· 1 1 import { getProfile, resolveHandle } from '$lib/bluesky.js'; 2 - import { getDetails } from '$lib/server/movies.js'; 3 - import { AtpBaseClient } from '@atproto/api'; 4 2 5 3 /** @type {import('./$types').PageServerLoad} */ 6 4 export async function load(event) { ··· 10 8 11 9 const profile = await getProfile({ did }); 12 10 13 - const agent = new AtpBaseClient({ service: 'https://bsky.social' }); 14 - const test = await agent.com.atproto.repo.listRecords({ 15 - repo: did, 16 - collection: 'my.skylights.rel' 17 - }); 18 - 19 - let isUser = false; 20 - if (event.locals.user?.did === did) { 21 - isUser = true; 22 - } 23 - 24 - const items = []; 25 - const promises = []; 26 - let count = 0; 27 - for (const record of test.data.records) { 28 - if (record.value.item.ref === 'tmdb:m') { 29 - const detailsPromise = getDetails(parseInt(record.value.item.value), 'movie').then( 30 - (details) => 31 - items.push({ 32 - movieId: parseInt(record.value.item.value), 33 - ...details, 34 - rating: record.value.rating.value / 2, 35 - ratingText: record.value.note?.value 36 - }) 37 - ); 38 - promises.push(detailsPromise); 39 - count++; 40 - } else if (record.value.item.ref === 'tmdb:s') { 41 - const detailsPromise = getDetails(parseInt(record.value.item.value), 'tv').then((details) => 42 - items.push({ 43 - showId: parseInt(record.value.item.value), 44 - ...details, 45 - rating: record.value.rating.value / 2, 46 - ratingText: record.value.note?.value 47 - }) 48 - ); 49 - promises.push(detailsPromise); 50 - count++; 51 - } 11 + const isUser = event.locals.user?.did === did; 52 12 53 - if (count > 10) { 54 - break; 55 - } 56 - } 57 - await Promise.all(promises); 58 - 59 - return { items, isUser, username: event.params.handle, profile }; 13 + return { isUser, username: event.params.handle, profile }; 60 14 }
+37 -11
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 { onMount } from 'svelte'; 5 6 6 7 let { data } = $props(); 8 + 9 + let items: { 10 + showId?: number; 11 + movieId?: number; 12 + poster_path?: string; 13 + original_title?: string; 14 + original_name?: string; 15 + rating?: number; 16 + ratingText?: string; 17 + }[] = $state([]); 18 + 19 + let loading = $state(true); 20 + 21 + onMount(async () => { 22 + const response = await fetch(`/api/getRatedWithDetails?did=${data.profile.did}`); 23 + const itemsData = await response.json(); 24 + items = itemsData.items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); 25 + loading = false; 26 + }); 7 27 </script> 8 28 9 29 <Container> 10 30 <Profile profile={data.profile} /> 11 31 12 - <ItemsGrid 13 - class="py-8 px-4" 14 - items={data.items.map((movie) => ({ 15 - poster_path: movie.poster_path ?? '', 16 - original_title: movie.original_title ?? movie.original_name, 17 - movieId: movie.movieId ?? undefined, 18 - showId: movie.showId ?? undefined, 19 - rating: movie.rating ?? undefined, 20 - ratingText: movie.ratingText ?? undefined 21 - }))} 22 - /> 32 + {#if items.length > 0} 33 + <ItemsGrid 34 + class="px-4 py-8" 35 + items={items.map((movie) => ({ 36 + poster_path: movie.poster_path ?? '', 37 + original_title: movie.original_title ?? movie.original_name ?? '', 38 + movieId: movie.movieId ?? undefined, 39 + showId: movie.showId ?? undefined, 40 + rating: movie.rating ?? undefined, 41 + ratingText: movie.ratingText ?? undefined 42 + }))} 43 + /> 44 + {:else if loading} 45 + <p class="text-center text-base-500 py-8">Loading reviews...</p> 46 + {:else} 47 + <p class="text-center text-base-500 py-8">No movies or tv shows rated yet.</p> 48 + {/if} 23 49 </Container>