[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 #101 from flo-bit/refactor-alpha-p1

refactor

authored by

Florian and committed by
GitHub
(Feb 7, 2025, 4:38 PM +0100) 500fbb47 e15bc42e

+2691 -2833
-14
scripts/author-dids.json
··· 1 - [ 2 - "did:plc:257wekqxg4hyapkq6k47igmp", 3 - "did:plc:wfml3smwyvohotnywklys5uq", 4 - "did:plc:myrtyr2rsjiivzlp5m6qevv5", 5 - "did:plc:xv2x4dimpe3zukzuynblaiyt", 6 - "did:plc:haldrwdq4aea5klterbxzsfs", 7 - "did:plc:izttpdp3l6vss5crelt5kcux", 8 - "did:plc:c764uyv2vgzswy5gpc4jgknf", 9 - "did:plc:2huehxznewvgzh3vknb36x5z", 10 - "did:plc:46c3ur7daizungsgji73qfky", 11 - "did:plc:sjxrdgjiotxnk6wapajdbn55", 12 - "did:plc:6x3sa4qcuhtc4yky2f6eu2ki", 13 - "did:plc:gysemcjbbnjru4f7reznddpk" 14 - ]
-22
src/lib/bluesky.ts
··· 1 - import { REL_COLLECTION } from '$lib'; 2 1 import { Agent, AtpBaseClient } from '@atproto/api'; 3 - import type { Record } from '@atproto/api/src/client/types/com/atproto/repo/listRecords'; 4 2 5 3 type AgentType = Agent | AtpBaseClient | null; 6 4 ··· 26 24 27 25 const { data } = await agent.app.bsky.actor.getProfile({ actor: did }); 28 26 return data; 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: Record[] = []; 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 27 }
-179
src/lib/state.svelte.ts
··· 1 - export const watchedItems = $state({ 2 - ratedMovies: new Map<number, { rating: number; ratingText?: string; updatedAt: string }>(), 3 - ratedShows: new Map<number, { rating: number; ratingText?: string; updatedAt: string }>(), 4 - 5 - hasRated: (item: { movieId?: number; showId?: number }) => { 6 - const id = item.movieId ?? item.showId; 7 - if (!id) return false; 8 - return item.movieId ? watchedItems.ratedMovies.has(id) : watchedItems.ratedShows.has(id); 9 - }, 10 - 11 - addRated: (item: { movieId?: number; showId?: number; rating: number; ratingText?: string }) => { 12 - const id = item.movieId ?? item.showId; 13 - if (!id) return; 14 - if (item.movieId) 15 - watchedItems.ratedMovies.set(id, { 16 - rating: item.rating, 17 - ratingText: item.ratingText, 18 - updatedAt: new Date().toISOString() 19 - }); 20 - else 21 - watchedItems.ratedShows.set(id, { 22 - rating: item.rating, 23 - ratingText: item.ratingText, 24 - updatedAt: new Date().toISOString() 25 - }); 26 - }, 27 - 28 - getRating: (item: { movieId?: number; showId?: number }) => { 29 - const id = item.movieId ?? item.showId; 30 - if (!id) return undefined; 31 - return item.movieId ? watchedItems.ratedMovies.get(id) : watchedItems.ratedShows.get(id); 32 - } 33 - }); 34 - 35 - type RateMovieModalItem = { 36 - movieId: number | undefined; 37 - showId: number | undefined; 38 - kind: string | undefined; 39 - name: string | undefined; 40 - posterPath: string | undefined; 41 - currentRating: number | undefined; 42 - currentReview: string | undefined; 43 - 44 - editUri?: string; 45 - }; 46 - 47 - export const rateMovieModal: { 48 - showModal: boolean; 49 - 50 - selectedItem: RateMovieModalItem; 51 - 52 - show: (item: RateMovieModalItem) => void; 53 - hide: () => void; 54 - showEmpty: () => void; 55 - } = $state({ 56 - showModal: false, 57 - 58 - selectedItem: { 59 - movieId: undefined, 60 - showId: undefined, 61 - kind: undefined, 62 - name: undefined, 63 - posterPath: undefined, 64 - currentRating: undefined, 65 - currentReview: undefined, 66 - 67 - editUri: undefined 68 - }, 69 - 70 - show: (item: RateMovieModalItem) => { 71 - rateMovieModal.selectedItem = item; 72 - rateMovieModal.showModal = true; 73 - }, 74 - 75 - hide: () => { 76 - rateMovieModal.showModal = false; 77 - }, 78 - 79 - showEmpty: () => { 80 - rateMovieModal.selectedItem = { 81 - movieId: undefined, 82 - showId: undefined, 83 - kind: undefined, 84 - name: undefined, 85 - posterPath: undefined, 86 - currentRating: undefined, 87 - currentReview: undefined, 88 - 89 - editUri: undefined 90 - }; 91 - rateMovieModal.showModal = true; 92 - } 93 - }); 94 - 95 - export const crosspostModal: { 96 - showModal: boolean; 97 - uri: string | undefined; 98 - review: string | undefined; 99 - rating: number | undefined; 100 - title: string | undefined; 101 - 102 - show: (uri: string, review: string, rating: number, title: string) => void; 103 - hide: () => void; 104 - } = $state({ 105 - showModal: false, 106 - uri: undefined, 107 - review: undefined, 108 - rating: undefined, 109 - title: undefined, 110 - 111 - show: (uri: string, review: string, rating: number, title: string) => { 112 - crosspostModal.uri = uri; 113 - crosspostModal.review = review; 114 - crosspostModal.rating = rating; 115 - crosspostModal.title = title; 116 - crosspostModal.showModal = true; 117 - }, 118 - 119 - hide: () => { 120 - crosspostModal.showModal = false; 121 - crosspostModal.uri = undefined; 122 - crosspostModal.review = undefined; 123 - crosspostModal.rating = undefined; 124 - crosspostModal.title = undefined; 125 - } 126 - }); 127 - 128 - export const settings: { 129 - streamingRegion: string | undefined; 130 - crosspostEnabled: boolean | undefined; 131 - } = $state({ 132 - streamingRegion: undefined, 133 - crosspostEnabled: undefined 134 - }); 135 - 136 - export const user: { 137 - displayName: string | undefined; 138 - handle: string | undefined; 139 - avatar: string | undefined; 140 - } = $state({ 141 - displayName: undefined, 142 - handle: undefined, 143 - avatar: undefined 144 - }); 145 - 146 - export const showLoginModal = $state({ 147 - value: false, 148 - toggle: () => { 149 - showLoginModal.value = !showLoginModal.value; 150 - } 151 - }); 152 - 153 - export const showSidebar = $state({ 154 - value: false, 155 - toggle: () => { 156 - showSidebar.value = !showSidebar.value; 157 - } 158 - }); 159 - 160 - export const videoPlayer: { 161 - showing: boolean; 162 - id: string | undefined; 163 - 164 - show: (id: string) => void; 165 - hide: () => void; 166 - } = $state({ 167 - showing: false, 168 - id: undefined, 169 - 170 - show: (id: string) => { 171 - videoPlayer.id = id; 172 - videoPlayer.showing = true; 173 - }, 174 - 175 - hide: () => { 176 - videoPlayer.id = undefined; 177 - videoPlayer.showing = false; 178 - } 179 - });
+37
src/lib/types.ts
··· 1 + export type Ref = `tmdb:m-${number}` | `tmdb:s-${number}`; 2 + 3 + export type Item = { 4 + id: number; 5 + 6 + ref: Ref; 7 + 8 + title: string; 9 + poster_path: string; 10 + backdrop_path: string; 11 + 12 + overview: string; 13 + 14 + release_date: string; 15 + first_air_date: string; 16 + media_type: 'movie' | 'tv'; 17 + 18 + genre_ids: number[]; 19 + 20 + vote_average: number; 21 + vote_count: number; 22 + 23 + popularity: number; 24 + 25 + original_title: string; 26 + original_language: string; 27 + 28 + video: boolean; 29 + 30 + trailer_url?: string; 31 + 32 + rating?: number; 33 + ratingText?: string; 34 + updatedAt?: string; 35 + 36 + uri?: string; 37 + };
+5
src/params/item.ts
··· 1 + import type { ParamMatcher } from '@sveltejs/kit'; 2 + 3 + export const match = ((param: string): param is 'movie' | 'tv' => { 4 + return param === 'movie' || param === 'tv'; 5 + }) satisfies ParamMatcher;
+10 -69
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import '../app.css'; 3 3 4 - import Footer from '$lib/Components/Footer.svelte'; 5 - import { toast, Toaster } from 'svelte-sonner'; 6 - import { settings, user, watchedItems } from '$lib/state.svelte'; 7 - import RateMovieModal from '$lib/Components/RateMovieModal.svelte'; 4 + import Footer from '$lib/Components/Layout/Footer.svelte'; 5 + import { Toaster } from 'svelte-sonner'; 6 + import { settings, user, watchedItems } from '$lib/state/user.svelte'; 7 + import RateMovieModal from '$lib/Components/Modals/RateMovieModal.svelte'; 8 8 9 - import LoginModal from '$lib/Components/LoginModal.svelte'; 10 - import Sidebar from '$lib/Components/Sidebar.svelte'; 11 - import VideoPlayer from '$lib/Components/VideoPlayer.svelte'; 9 + import LoginModal from '$lib/Components/Modals/LoginModal.svelte'; 10 + import Sidebar from '$lib/Components/Layout/Sidebar.svelte'; 11 + import VideoPlayer from '$lib/Components/Utils/VideoPlayer.svelte'; 12 12 13 13 import { onNavigate } from '$app/navigation'; 14 14 import { onMount } from 'svelte'; 15 15 import { navigating } from '$app/stores'; 16 16 import { slide } from 'svelte/transition'; 17 17 import { expoOut } from 'svelte/easing'; 18 - import CrosspostModal from '$lib/Components/CrosspostModal.svelte'; 18 + import CrosspostModal from '$lib/Components/Modals/CrosspostModal.svelte'; 19 19 20 20 let { children, data } = $props(); 21 21 ··· 35 35 }); 36 36 }); 37 37 38 - function useCachedRatedItems() { 39 - try { 40 - // if no version number is set or it's not 1, clear the cache 41 - const version = localStorage.getItem('skywatched-version'); 42 - if (!version || version !== '1') { 43 - localStorage.clear(); 44 - return false; 45 - } 46 - 47 - const cachedRatedItems = localStorage.getItem(`ratedItems-${data.user.did}`); 48 - const lastUpdate = localStorage.getItem(`ratedItems-${data.user.did}-lastUpdate`); 49 - if ( 50 - cachedRatedItems && 51 - lastUpdate && 52 - new Date(lastUpdate).getTime() + 10 * 60 * 1000 > Date.now() 53 - ) { 54 - watchedItems.ratedMovies = new Map( 55 - JSON.parse(cachedRatedItems).movies.map((movie: any) => [movie.id, movie]) 56 - ); 57 - watchedItems.ratedShows = new Map( 58 - JSON.parse(cachedRatedItems).shows.map((show: any) => [show.id, show]) 59 - ); 60 - 61 - return true; 62 - } 63 - } catch (error) { 64 - console.error('Error fetching rated items', error); 65 - } 66 - 67 - return false; 68 - } 69 - 70 - async function fetchRatedItems() { 71 - try { 72 - // if not, fetch them 73 - const response = await fetch(`/api/getAllRated?did=${data.user.did}`); 74 - const items = await response.json(); 75 - 76 - watchedItems.ratedMovies = new Map(items.movies.map((movie: any) => [movie.id, movie])); 77 - watchedItems.ratedShows = new Map(items.shows.map((show: any) => [show.id, show])); 78 - localStorage.setItem(`ratedItems-${data.user.did}`, JSON.stringify(items)); 79 - localStorage.setItem(`ratedItems-${data.user.did}-lastUpdate`, new Date().toISOString()); 80 - 81 - console.log('fetched rated items'); 82 - 83 - return true; 84 - } catch (error) { 85 - console.error('Error fetching rated items', error); 86 - return false; 87 - } 88 - } 89 - 90 38 onMount(async () => { 91 39 // check if user is logged in 92 40 if (!data.user) return; ··· 97 45 user.displayName = data.user.displayName; 98 46 user.handle = data.user.handle; 99 47 user.avatar = data.user.avatar; 48 + user.did = data.user.did; 100 49 101 - if (useCachedRatedItems()) { 102 - console.log('using cached rated items'); 103 - } else { 104 - console.log('fetching rated items'); 105 - if (!(await fetchRatedItems())) { 106 - console.error('Error fetching rated items'); 107 - toast.error('Error getting your rated items'); 108 - } 109 - } 50 + watchedItems.load(); 110 51 }); 111 52 </script> 112 53
+4 -4
src/routes/+page.svelte
··· 1 1 <script> 2 - import BaseHeadTags from '$lib/Components/BaseHeadTags.svelte'; 3 - import Container from '$lib/Components/Container.svelte'; 4 - import ReviewList from '$lib/Components/ReviewList.svelte'; 5 - import { rateMovieModal, showLoginModal } from '$lib/state.svelte'; 2 + import BaseHeadTags from '$lib/Components/Layout/BaseHeadTags.svelte'; 3 + import Container from '$lib/Components/Layout/Container.svelte'; 4 + import ReviewList from '$lib/Components/Items/ReviewList.svelte'; 5 + import { rateMovieModal, showLoginModal } from '$lib/state/modals.svelte'; 6 6 7 7 const { data } = $props(); 8 8 </script>
-43
src/lib/Components/Avatar.svelte
··· 1 - <script lang="ts"> 2 - import { cn } from '$lib/utils'; 3 - 4 - type Props = { 5 - link?: string; 6 - size?: string; 7 - src?: string; 8 - alt?: string; 9 - }; 10 - 11 - const { link, size = 'size-11', src, alt }: Props = $props(); 12 - </script> 13 - 14 - {#snippet avatar()} 15 - <div 16 - class={cn( 17 - 'overflow-hidden rounded-full border border-base-400/50 bg-base-100 dark:border-base-700 dark:bg-base-800', 18 - size 19 - )} 20 - > 21 - {#if src} 22 - <img loading="lazy" class="h-full w-full object-cover" {src} {alt} /> 23 - {:else} 24 - <svg 25 - class="size-full text-base-300 dark:text-base-600" 26 - fill="currentColor" 27 - viewBox="0 0 24 24" 28 - > 29 - <path 30 - d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" 31 - /> 32 - </svg> 33 - {/if} 34 - </div> 35 - {/snippet} 36 - 37 - {#if link} 38 - <a href={link} class="inline-block" target="_blank" rel="noopener noreferrer nofollow"> 39 - {@render avatar()} 40 - </a> 41 - {:else} 42 - {@render avatar()} 43 - {/if}
-22
src/lib/Components/BaseHeadTags.svelte
··· 1 - <svelte:head> 2 - <meta name="description" content="review movies and shows with your friends from bluesky." /> 3 - 4 - <meta property="og:url" content="https://skywatched.app" /> 5 - <meta property="og:type" content="website" /> 6 - <meta property="og:title" content="skywatched" /> 7 - <meta 8 - property="og:description" 9 - content="review movies and shows with your friends from bluesky." 10 - /> 11 - <meta property="og:image" content="/og.jpg" /> 12 - 13 - <meta name="twitter:card" content="summary_large_image" /> 14 - <meta property="twitter:domain" content="skywatched.app" /> 15 - <meta property="twitter:url" content="https://skywatched.app" /> 16 - <meta name="twitter:title" content="skywatched" /> 17 - <meta 18 - name="twitter:description" 19 - content="review movies and shows with your friends from bluesky." 20 - /> 21 - <meta name="twitter:image" content="/og.jpg" /> 22 - </svelte:head>
-9
src/lib/Components/Container.svelte
··· 1 - <script lang="ts"> 2 - import { cn } from '../utils'; 3 - 4 - const { children, class: className }: { children: () => any; class?: string } = $props(); 5 - </script> 6 - 7 - <div class={cn('mx-auto max-w-2xl sm:px-6 lg:max-w-4xl lg:px-8', className)}> 8 - {@render children?.()} 9 - </div>
-124
src/lib/Components/CrosspostModal.svelte
··· 1 - <script lang="ts"> 2 - import { crosspostModal, user } from '$lib/state.svelte'; 3 - import { toast } from 'svelte-sonner'; 4 - 5 - let ratingText = $derived.by(() => { 6 - const ratingText = `I rated "${crosspostModal.title}" with ${crosspostModal.rating} star${crosspostModal.rating === 1 ? '' : 's'} on skywatched.app`; 7 - 8 - let text = crosspostModal.review 9 - ? `My review for "${crosspostModal.title}" on skywatched.app:<br /> <br />${crosspostModal.review}` 10 - : ratingText; 11 - 12 - if (text.length > 299) { 13 - text = text.slice(0, 295) + '...'; 14 - } 15 - 16 - return text; 17 - }); 18 - </script> 19 - 20 - {#if crosspostModal.showModal} 21 - <div class="relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true"> 22 - <div 23 - class="fixed inset-0 bg-base-950/90 backdrop-blur-sm transition-opacity" 24 - onclick={() => (crosspostModal.showModal = false)} 25 - aria-hidden="true" 26 - ></div> 27 - <div class="pointer-events-none fixed inset-0 z-50 h-[100dvh] w-screen overflow-y-auto"> 28 - <div class="flex h-[100dvh] items-end justify-center p-4 text-center sm:items-center sm:p-0"> 29 - <div 30 - class="pointer-events-auto relative w-full transform overflow-hidden rounded-lg border border-base-800 bg-base-900 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:max-w-md sm:p-6" 31 - > 32 - <button 33 - class="absolute right-2 top-2 rounded-full bg-base-800/50 p-1 hover:bg-base-800/80" 34 - onclick={() => (crosspostModal.showModal = false)} 35 - > 36 - <svg 37 - xmlns="http://www.w3.org/2000/svg" 38 - fill="none" 39 - viewBox="0 0 24 24" 40 - stroke-width="1.5" 41 - stroke="currentColor" 42 - class="size-4" 43 - > 44 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 45 - </svg> 46 - 47 - <span class="sr-only">close</span> 48 - </button> 49 - 50 - <div> 51 - <h3 class="text-md mb-4 font-semibold text-base-50" id="modal-title"> 52 - Crosspost to Bluesky 53 - </h3> 54 - 55 - <p class="text-sm text-base-300">Do you want to crosspost this review to Bluesky?</p> 56 - 57 - <div class="mt-4 rounded-xl border border-base-800 bg-base-950 p-4"> 58 - <div class="flex items-center gap-2"> 59 - {#if user.avatar} 60 - <img src={user.avatar} alt="user avatar" class="size-5 rounded-full" /> 61 - {/if} 62 - <div class="truncate text-sm font-medium"> 63 - {user.displayName || user.handle} 64 - </div> 65 - </div> 66 - 67 - <p class="mt-2 text-sm text-base-300"> 68 - {@html ratingText} 69 - </p> 70 - 71 - <div 72 - class="relative mt-4 aspect-2 h-auto w-full overflow-hidden rounded-md border border-base-800 bg-base-800" 73 - > 74 - <div 75 - class="absolute inset-0 flex h-full w-full items-center justify-center bg-base-950/90" 76 - > 77 - <p>Loading image...</p> 78 - </div> 79 - <img 80 - src="/review/{encodeURIComponent(crosspostModal.uri ?? '')}/og.png" 81 - alt="crosspost" 82 - class="absolute inset-0 h-full w-full object-cover" 83 - /> 84 - </div> 85 - </div> 86 - </div> 87 - <div class="mt-5 sm:mt-6"> 88 - <button 89 - onclick={async () => { 90 - crosspostModal.hide(); 91 - }} 92 - type="button" 93 - class="inline-flex w-full justify-center rounded-md border border-base-700 bg-white/5 px-3 py-2 text-sm font-semibold text-base-300 shadow-sm hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600" 94 - >No thanks</button 95 - > 96 - 97 - <button 98 - onclick={async () => { 99 - crosspostModal.showModal = false; 100 - 101 - const response = await fetch(`/api/crosspost`, { 102 - method: 'POST', 103 - body: JSON.stringify({ 104 - uri: crosspostModal.uri 105 - }) 106 - }); 107 - 108 - if (!response.ok) { 109 - toast.error('Failed to crosspost'); 110 - return; 111 - } 112 - 113 - toast.success('Crossposted to Bluesky'); 114 - }} 115 - type="button" 116 - class="mt-2 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" 117 - >Post to Bluesky</button 118 - > 119 - </div> 120 - </div> 121 - </div> 122 - </div> 123 - </div> 124 - {/if}
-30
src/lib/Components/Footer.svelte
··· 1 - <footer class="relative z-10 border-t border-base-800 bg-black"> 2 - <div 3 - class="mx-auto max-w-2xl py-12 sm:px-6 md:flex md:items-center md:justify-between lg:max-w-4xl lg:px-8 xl:max-w-6xl" 4 - > 5 - <div class="flex justify-center gap-x-6 md:order-2"> 6 - <a 7 - href="https://github.com/flo-bit/skywatched" 8 - class="text-base-400 hover:text-base-300" 9 - target="_blank" 10 - > 11 - <span class="sr-only">see source code</span> 12 - <svg class="size-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> 13 - <path 14 - fill-rule="evenodd" 15 - d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" 16 - clip-rule="evenodd" 17 - /> 18 - </svg> 19 - </a> 20 - </div> 21 - <div class="mt-8 flex flex-col items-center md:order-1 md:items-start"> 22 - <a href="https://www.themoviedb.org/" target="_blank"> 23 - <img src="/tmdb_logo.svg" alt="TMDB logo" class="h-3" height="12" width="165" /> 24 - </a> 25 - <p class="mt-2 text-center text-xs text-base-400"> 26 - This website uses the TMDB API but is not endorsed or certified by TMDB. 27 - </p> 28 - </div> 29 - </div> 30 - </footer>
-9
src/lib/Components/Input.svelte
··· 1 - <script lang="ts"> 2 - let { value = $bindable(), ...rest } = $props(); 3 - </script> 4 - 5 - <input 6 - bind:value 7 - class="block w-full rounded-full 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 dark:bg-base-950 dark:text-base-100 dark:ring-base-700 dark:placeholder:text-base-600 sm:text-sm/6" 8 - {...rest} 9 - />
-100
src/lib/Components/ItemCard.svelte
··· 1 - <script lang="ts"> 2 - import { rateMovieModal, watchedItems } from '$lib/state.svelte'; 3 - import { cn, nameToId } from '$lib/utils'; 4 - import Rating from './Rating.svelte'; 5 - 6 - const { 7 - item, 8 - showMark 9 - }: { 10 - item: { 11 - poster_path: string; 12 - title?: string; 13 - name?: string; 14 - movieId?: number; 15 - showId?: number; 16 - rating?: number; 17 - }; 18 - showMark?: boolean; 19 - } = $props(); 20 - </script> 21 - 22 - <div class="group relative"> 23 - <div 24 - class="pointer-events-none relative z-20 aspect-[2/3] h-auto min-h-44 w-full overflow-hidden rounded-md border border-base-800 bg-base-900/50 transition-opacity duration-75 group-hover:opacity-75 sm:min-h-44" 25 - > 26 - {#if item.poster_path} 27 - <img 28 - src="https://image.tmdb.org/t/p/w342{item.poster_path}" 29 - alt="movie poster for {item.title ?? item.name}" 30 - class="poster size-full object-cover object-center lg:size-full" 31 - loading="lazy" 32 - /> 33 - {/if} 34 - 35 - {#if item.rating} 36 - <div 37 - class="absolute inset-0 z-10 bg-gradient-to-b from-transparent via-black/30 to-black/70" 38 - ></div> 39 - <div class="absolute bottom-2 left-0 right-0 z-10 flex justify-center"> 40 - <Rating rating={item.rating} /> 41 - </div> 42 - {:else if showMark} 43 - {#if watchedItems.hasRated(item)} 44 - <div 45 - class="absolute inset-0 z-10 bg-gradient-to-b from-transparent via-black/30 to-black/70" 46 - ></div> 47 - <div class="absolute bottom-2 left-0 right-0 z-10 flex justify-center"> 48 - <Rating rating={watchedItems.getRating(item)?.rating ?? 0} /> 49 - </div> 50 - {:else} 51 - <button 52 - class={cn( 53 - 'pointer-events-auto absolute bottom-2 right-2 z-10 rounded-full border border-base-50/20 bg-black/30 p-2 text-base-50 opacity-100 backdrop-blur-sm transition-opacity duration-300 ease-in-out group-hover:opacity-100 sm:opacity-0', 54 - watchedItems.hasRated(item) 55 - ? 'border-green-500/20 bg-green-900/60 text-green-500 sm:opacity-100' 56 - : 'hover:border-accent-800/50 hover:bg-accent-900/50 hover:text-accent-400 hover:duration-0' 57 - )} 58 - onclick={() => { 59 - rateMovieModal.show({ 60 - movieId: item.movieId, 61 - showId: item.showId, 62 - kind: item.movieId ? 'movie' : 'tv', 63 - name: item.title ?? item.name, 64 - currentRating: 0, 65 - currentReview: '', 66 - posterPath: item.poster_path 67 - }); 68 - }} 69 - > 70 - <span class="sr-only">rate</span> 71 - 72 - <svg 73 - xmlns="http://www.w3.org/2000/svg" 74 - fill="none" 75 - viewBox="0 0 24 24" 76 - stroke-width="2.5" 77 - stroke="currentColor" 78 - class="size-5" 79 - > 80 - <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 81 - </svg> 82 - </button> 83 - {/if} 84 - {/if} 85 - </div> 86 - <div class="mt-2 flex justify-between"> 87 - <h3 class="sm:text-md text-sm font-medium text-base-50"> 88 - <a 89 - href="/{item.movieId ? 'movie' : 'tv'}/{item.movieId ?? item.showId}-{nameToId( 90 - item.title ?? item.name ?? '' 91 - )}" 92 - > 93 - <span aria-hidden="true" class="absolute inset-0"></span> 94 - <div class="line-clamp-2 max-w-full"> 95 - {item.title ?? item.name} 96 - </div> 97 - </a> 98 - </h3> 99 - </div> 100 - </div>
-25
src/lib/Components/ItemsGrid.svelte
··· 1 - <script lang="ts"> 2 - import { cn } from '../utils'; 3 - import ItemCard from './ItemCard.svelte'; 4 - 5 - const { 6 - items, 7 - class: className, 8 - showMark 9 - }: { 10 - items: { poster_path: string; original_title: string; movieId?: number; showId?: number }[]; 11 - class?: string; 12 - showMark?: boolean; 13 - } = $props(); 14 - </script> 15 - 16 - <div 17 - class={cn( 18 - 'mt-6 grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 xl:gap-x-8', 19 - className 20 - )} 21 - > 22 - {#each items as item} 23 - <ItemCard {item} {showMark} /> 24 - {/each} 25 - </div>
-20
src/lib/Components/ItemsList.svelte
··· 1 - <script lang="ts"> 2 - import { cn } from '../utils'; 3 - import ItemCard from './ItemCard.svelte'; 4 - 5 - const { 6 - items, 7 - class: className, 8 - showMark 9 - }: { 10 - items: { poster_path: string; original_title: string; movieId?: number; showId?: number }[]; 11 - class?: string; 12 - showMark?: boolean; 13 - } = $props(); 14 - </script> 15 - 16 - <div class={cn('flex gap-x-6 overflow-x-auto', className)}> 17 - {#each items as item} 18 - <ItemCard {item} {showMark} /> 19 - {/each} 20 - </div>
-73
src/lib/Components/LoginModal.svelte
··· 1 - <script lang="ts"> 2 - import { fade, fly, slide } from 'svelte/transition'; 3 - import { showLoginModal } from '$lib/state.svelte'; 4 - </script> 5 - 6 - {#if showLoginModal.value} 7 - <div 8 - class="fixed inset-0 z-[100] w-screen overflow-y-auto" 9 - aria-labelledby="modal-title" 10 - role="dialog" 11 - aria-modal="true" 12 - > 13 - <div 14 - class="fixed inset-0 bg-base-950/75 backdrop-blur-sm transition-opacity" 15 - onclick={() => showLoginModal.toggle()} 16 - aria-hidden="true" 17 - ></div> 18 - 19 - <div class="pointer-events-none fixed inset-0 z-10 w-screen overflow-y-auto"> 20 - <div 21 - class="flex min-h-[100dvh] items-end justify-center p-4 text-center sm:items-center sm:p-0" 22 - > 23 - <div 24 - class="pointer-events-auto relative w-full transform overflow-hidden rounded-lg border border-base-700 bg-base-800 px-4 pb-4 pt-2 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6" 25 - > 26 - <div> 27 - <div class="text-center"> 28 - <h3 class="text-base font-semibold text-base-900 dark:text-base-100" id="modal-title"> 29 - Login with Bluesky 30 - </h3> 31 - </div> 32 - </div> 33 - <form action="/?/login" method="POST" class="mt-2 flex w-full flex-col gap-2"> 34 - <div class="w-full"> 35 - <label 36 - for="handle" 37 - class="block text-sm/6 font-medium text-base-900 dark:text-base-100">Handle</label 38 - > 39 - <div class="mt-2"> 40 - <input 41 - type="text" 42 - name="handle" 43 - id="handle" 44 - 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 dark:bg-base-900 dark:text-base-100 dark:ring-base-700 dark:placeholder:text-base-600 sm:text-sm/6" 45 - placeholder="yourname.bsky.social" 46 - /> 47 - </div> 48 - </div> 49 - <div class="mt-5 sm:mt-6"> 50 - <button 51 - type="submit" 52 - class="inline-flex w-full justify-center rounded-md bg-accent-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-accent-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600" 53 - >Login</button 54 - > 55 - </div> 56 - 57 - <div class="mt-4 border-t border-base-700 pt-4 text-sm leading-7 text-base-300"> 58 - Don't have an account? 59 - <br /> 60 - <a 61 - href="https://bsky.app" 62 - target="_blank" 63 - class="font-medium text-accent-400 hover:text-accent-500" 64 - > 65 - Create one on bluesky 66 - </a> and come back here. 67 - </div> 68 - </form> 69 - </div> 70 - </div> 71 - </div> 72 - </div> 73 - {/if}
-18
src/lib/Components/Logo.svelte
··· 1 - <script lang="ts"> 2 - import { cn } from '../utils'; 3 - 4 - let { class: className }: { class: string } = $props(); 5 - </script> 6 - 7 - <svg 8 - xmlns="http://www.w3.org/2000/svg" 9 - viewBox="0 0 24 24" 10 - fill="currentColor" 11 - class={cn('size-6 fill-accent-500', className)} 12 - > 13 - <path 14 - fill-rule="evenodd" 15 - d="M9.528 1.718a.75.75 0 0 1 .162.819A8.97 8.97 0 0 0 9 6a9 9 0 0 0 9 9 8.97 8.97 0 0 0 3.463-.69.75.75 0 0 1 .981.98 10.503 10.503 0 0 1-9.694 6.46c-5.799 0-10.5-4.7-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 0 1 .818.162Z" 16 - clip-rule="evenodd" 17 - /> 18 - </svg>
-34
src/lib/Components/Note.svelte
··· 1 - <script lang="ts"> 2 - import { cn } from '$lib/utils'; 3 - 4 - let { 5 - children, 6 - class: className 7 - }: { 8 - children: () => any; 9 - class?: string; 10 - } = $props(); 11 - </script> 12 - 13 - <div class={cn('rounded-md border border-sky-500/20 bg-sky-500/10 p-4', className)}> 14 - <div class="flex"> 15 - <div class="shrink-0"> 16 - <svg 17 - class="size-5 text-sky-500" 18 - viewBox="0 0 20 20" 19 - fill="currentColor" 20 - aria-hidden="true" 21 - data-slot="icon" 22 - > 23 - <path 24 - fill-rule="evenodd" 25 - d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" 26 - clip-rule="evenodd" 27 - /> 28 - </svg> 29 - </div> 30 - <div class="ml-3 flex-1 md:flex md:justify-between"> 31 - <p class="text-sm text-sky-400">{@render children()}</p> 32 - </div> 33 - </div> 34 - </div>
-197
src/lib/Components/OptionButton.svelte
··· 1 - <script lang="ts"> 2 - import { type MainRecord } from '$lib/db'; 3 - import { rateMovieModal, user } from '$lib/state.svelte'; 4 - import { createDropdownMenu, melt } from '@melt-ui/svelte'; 5 - import { toast } from 'svelte-sonner'; 6 - import { fly } from 'svelte/transition'; 7 - 8 - const { 9 - elements: { trigger, menu }, 10 - states: { open } 11 - } = createDropdownMenu({ 12 - forceVisible: true, 13 - loop: true 14 - }); 15 - 16 - export let data: MainRecord; 17 - </script> 18 - 19 - <button type="button" class="" use:melt={$trigger}> 20 - <svg 21 - xmlns="http://www.w3.org/2000/svg" 22 - fill="none" 23 - viewBox="0 0 24 24" 24 - stroke-width="1.5" 25 - stroke="currentColor" 26 - class="size-4" 27 - > 28 - <path 29 - stroke-linecap="round" 30 - stroke-linejoin="round" 31 - d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" 32 - /> 33 - </svg> 34 - 35 - <span class="sr-only">Open Popover</span> 36 - </button> 37 - 38 - {#if $open} 39 - <div 40 - class="bg-base-900 border-base-800 shadow-xl shadow-base-950 divide-base-800 z-20 flex flex-col items-start divide-y overflow-hidden rounded-xl border" 41 - use:melt={$menu} 42 - transition:fly={{ duration: 150, y: -10 }} 43 - > 44 - {#if data.author.handle === user?.handle} 45 - <button 46 - class="item" 47 - on:click={async () => { 48 - const response = await fetch(`/api/review?uri=${encodeURIComponent(data.uri)}`, { 49 - method: 'DELETE' 50 - }); 51 - console.log(await response.json()); 52 - 53 - $open = false; 54 - 55 - await new Promise((resolve) => setTimeout(resolve, 1000)); 56 - 57 - if (window.location.pathname.includes('/review/')) { 58 - window.location.href = '/'; 59 - } else { 60 - window.location.reload(); 61 - } 62 - }} 63 - ><svg 64 - xmlns="http://www.w3.org/2000/svg" 65 - fill="none" 66 - viewBox="0 0 24 24" 67 - stroke-width="1" 68 - stroke="currentColor" 69 - class="size-4" 70 - > 71 - <path 72 - stroke-linecap="round" 73 - stroke-linejoin="round" 74 - d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" 75 - /> 76 - </svg> 77 - 78 - delete review</button 79 - > 80 - <button 81 - class="item" 82 - on:click={() => { 83 - $open = false; 84 - 85 - console.log(data.record.rating?.value) 86 - rateMovieModal.show({ 87 - movieId: data.record.item.ref === 'tmdb:m' ? parseInt(data.record.item.value) : undefined, 88 - showId: data.record.item.ref === 'tmdb:s' ? parseInt(data.record.item.value) : undefined, 89 - kind: data.record.item.ref === 'tmdb:s' ? 'show' : 'movie', 90 - name: data.record.metadata?.title, 91 - posterPath: data.record.metadata?.poster_path, 92 - currentRating: (data.record.rating?.value ?? 0) / 2, 93 - currentReview: data.record.note?.value, 94 - editUri: data.uri 95 - }); 96 - }} 97 - ><svg 98 - xmlns="http://www.w3.org/2000/svg" 99 - fill="none" 100 - viewBox="0 0 24 24" 101 - stroke-width="1" 102 - stroke="currentColor" 103 - class="size-4" 104 - > 105 - <path 106 - stroke-linecap="round" 107 - stroke-linejoin="round" 108 - d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" 109 - /> 110 - </svg> 111 - 112 - edit review</button 113 - > 114 - {:else} 115 - <!-- <button 116 - class="item" 117 - on:click={() => { 118 - alert('Check for Updates...'); 119 - }} 120 - > 121 - <svg 122 - xmlns="http://www.w3.org/2000/svg" 123 - fill="none" 124 - viewBox="0 0 24 24" 125 - stroke-width="1" 126 - stroke="currentColor" 127 - class="size-4" 128 - > 129 - <path 130 - stroke-linecap="round" 131 - stroke-linejoin="round" 132 - d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" 133 - /> 134 - </svg> 135 - 136 - report review</button 137 - > --> 138 - {/if} 139 - 140 - {#if data.record.crosspost?.uri} 141 - <button class="item" on:click={() => {}}> 142 - <svg 143 - xmlns="http://www.w3.org/2000/svg" 144 - fill="none" 145 - viewBox="0 0 24 24" 146 - stroke-width="1" 147 - stroke="currentColor" 148 - class="size-4" 149 - > 150 - <path 151 - stroke-linecap="round" 152 - stroke-linejoin="round" 153 - d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3" 154 - /> 155 - </svg> 156 - go to crosspost 157 - </button> 158 - {/if} 159 - 160 - <button 161 - class="item" 162 - on:click={() => { 163 - $open = false; 164 - 165 - // copy link to clipboard 166 - navigator.clipboard.writeText( 167 - 'https://skywatched.app/review/' + encodeURIComponent(data.uri) 168 - ); 169 - 170 - toast.success('Link copied to clipboard'); 171 - }} 172 - > 173 - <svg 174 - xmlns="http://www.w3.org/2000/svg" 175 - fill="none" 176 - viewBox="0 0 24 24" 177 - stroke-width="1" 178 - stroke="currentColor" 179 - class="size-4" 180 - > 181 - <path 182 - stroke-linecap="round" 183 - stroke-linejoin="round" 184 - d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5A3.375 3.375 0 0 0 6.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0 0 15 2.25h-1.5a2.251 2.251 0 0 0-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 0 0-9-9Z" 185 - /> 186 - </svg> 187 - 188 - copy link to post 189 - </button> 190 - </div> 191 - {/if} 192 - 193 - <style> 194 - .item { 195 - @apply text-base-300 hover:bg-accent-950/30 hover:text-accent-400 inline-flex w-full items-center gap-2 px-3 py-2 text-sm; 196 - } 197 - </style>
-58
src/lib/Components/Profile.svelte
··· 1 - <script lang="ts"> 2 - import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 3 - import Avatar from './Avatar.svelte'; 4 - import { cn } from '$lib/utils'; 5 - 6 - let { profile }: { profile: ProfileViewDetailed } = $props(); 7 - </script> 8 - 9 - {#if profile} 10 - <div class="mx-auto max-w-full sm:max-w-2xl sm:py-6"> 11 - <div> 12 - {#if profile.banner} 13 - <img 14 - class="aspect-[3/1] w-full border-b border-base-800 object-cover sm:rounded-xl sm:border" 15 - src={profile.banner} 16 - alt="" 17 - /> 18 - {:else} 19 - <div class="aspect-[8/1] w-full"></div> 20 - {/if} 21 - </div> 22 - <div 23 - class={cn( 24 - profile.banner ? '-mt-11' : '-mt-8', 25 - 'flex max-w-full items-end space-x-5 px-4 sm:-mt-16 sm:px-6 lg:px-8' 26 - )} 27 - > 28 - <Avatar src={profile.avatar} size="size-24 sm:size-32" /> 29 - <div 30 - class="flex min-w-0 flex-1 flex-row sm:flex-row sm:items-center sm:justify-end sm:space-x-6 sm:pb-1" 31 - > 32 - <div 33 - class={cn( 34 - profile.banner ? 'mt-4 sm:mt-0' : '-mt-[4.5rem] sm:-mt-[6.5rem]', 35 - 'flex min-w-0 max-w-full flex-1 flex-col items-baseline' 36 - )} 37 - > 38 - <h1 39 - class="max-w-full truncate text-lg font-bold text-base-900 dark:text-base-100 sm:text-xl" 40 - > 41 - {profile.displayName || profile.handle} 42 - </h1> 43 - <div class="truncate text-xs text-base-900 dark:text-base-400 sm:text-sm"> 44 - @{profile.handle} 45 - </div> 46 - </div> 47 - <div class="mt-6 hidden flex-row justify-stretch space-x-4 space-y-0"> 48 - <button 49 - type="button" 50 - class="inline-flex justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-base-900 shadow-sm ring-1 ring-inset ring-base-300 hover:bg-base-50" 51 - > 52 - <span>Call</span> 53 - </button> 54 - </div> 55 - </div> 56 - </div> 57 - </div> 58 - {/if}
-187
src/lib/Components/RateMovieModal.svelte
··· 1 - <script lang="ts"> 2 - import { crosspostModal, rateMovieModal, settings, watchedItems } from '$lib/state.svelte'; 3 - import { toast } from 'svelte-sonner'; 4 - import Rating from './Rating.svelte'; 5 - import SearchCombobox from './SearchCombobox.svelte'; 6 - 7 - let rating = $state(rateMovieModal.selectedItem.currentRating ?? 0); 8 - let review = $state(rateMovieModal.selectedItem.currentReview ?? ''); 9 - 10 - $effect(() => { 11 - rating = rateMovieModal.selectedItem.currentRating ?? 0; 12 - review = rateMovieModal.selectedItem.currentReview ?? ''; 13 - }); 14 - 15 - let sending = $state(false); 16 - 17 - async function saveNew() { 18 - const response = await fetch(`/api/rate`, { 19 - method: 'POST', 20 - body: JSON.stringify({ 21 - rating, 22 - review, 23 - kind: rateMovieModal.selectedItem.kind, 24 - id: rateMovieModal.selectedItem.movieId ?? rateMovieModal.selectedItem.showId 25 - }) 26 - }); 27 - 28 - if (!response.ok) { 29 - toast.error('Failed to save rating'); 30 - 31 - return; 32 - } 33 - rateMovieModal.showModal = false; 34 - 35 - const data = await response.json(); 36 - 37 - watchedItems.addRated({ 38 - movieId: rateMovieModal.selectedItem.movieId, 39 - showId: rateMovieModal.selectedItem.showId, 40 - rating 41 - }); 42 - 43 - toast.success('Rating saved'); 44 - 45 - if (settings.crosspostEnabled && review.length > 0) { 46 - await new Promise((resolve) => setTimeout(resolve, 1000)); 47 - 48 - crosspostModal.show(data.uri, review, rating, rateMovieModal.selectedItem.name ?? ''); 49 - } 50 - } 51 - 52 - async function saveEdit() { 53 - 54 - const response = await fetch(`/api/review`, { 55 - method: 'PUT', 56 - body: JSON.stringify({ 57 - rating, 58 - review, 59 - kind: rateMovieModal.selectedItem.kind, 60 - id: rateMovieModal.selectedItem.movieId ?? rateMovieModal.selectedItem.showId, 61 - uri: rateMovieModal.selectedItem.editUri 62 - }) 63 - }); 64 - 65 - if (!response.ok) { 66 - toast.error('Failed to save rating'); 67 - 68 - return; 69 - } 70 - 71 - toast.success('Rating saved'); 72 - 73 - rateMovieModal.showModal = false; 74 - 75 - await new Promise((resolve) => setTimeout(resolve, 1000)); 76 - 77 - window.location.reload(); 78 - } 79 - </script> 80 - 81 - {#if rateMovieModal.showModal} 82 - <div class="relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true"> 83 - <div 84 - class="bg-base-950/90 fixed inset-0 backdrop-blur-sm transition-opacity" 85 - onclick={() => (rateMovieModal.showModal = false)} 86 - aria-hidden="true" 87 - ></div> 88 - <div class="pointer-events-none fixed inset-0 z-50 h-[100dvh] w-screen overflow-y-auto"> 89 - <div class="flex h-[100dvh] items-end justify-center p-4 text-center sm:items-center sm:p-0"> 90 - <div 91 - class="border-base-800 bg-base-900 pointer-events-auto relative w-full transform overflow-hidden rounded-lg border px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:max-w-sm sm:p-6" 92 - > 93 - <button 94 - class="bg-base-800/50 hover:bg-base-800/80 absolute right-2 top-2 rounded-full p-1" 95 - onclick={() => (rateMovieModal.showModal = false)} 96 - > 97 - <svg 98 - xmlns="http://www.w3.org/2000/svg" 99 - fill="none" 100 - viewBox="0 0 24 24" 101 - stroke-width="1.5" 102 - stroke="currentColor" 103 - class="size-4" 104 - > 105 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 106 - </svg> 107 - 108 - <span class="sr-only">close</span> 109 - </button> 110 - 111 - <div> 112 - <h3 class="text-md text-base-50 mb-4 font-semibold" id="modal-title"> 113 - {#if rateMovieModal.selectedItem.editUri} 114 - Edit review 115 - {:else} 116 - Rate and review 117 - {/if} 118 - </h3> 119 - 120 - {#if rateMovieModal.selectedItem.name} 121 - <div class="relative flex items-center gap-4"> 122 - <div 123 - class="border-base-800 bg-base-900/50 relative z-20 aspect-[2/3] h-32 w-auto shrink-0 overflow-hidden rounded-md border" 124 - > 125 - {#if rateMovieModal.selectedItem.posterPath} 126 - <img 127 - src="https://image.tmdb.org/t/p/w154{rateMovieModal.selectedItem.posterPath}" 128 - alt="movie poster for {rateMovieModal.selectedItem.name}" 129 - class="size-full object-cover object-center lg:size-full" 130 - /> 131 - {/if} 132 - </div> 133 - <h3 134 - class="text-base-50 mb-4 flex flex-col gap-2 text-xl font-semibold" 135 - id="modal-title" 136 - > 137 - {rateMovieModal.selectedItem.name} 138 - 139 - <Rating bind:rating canChange size="size-7" /> 140 - </h3> 141 - <!-- <div class="absolute right-2 top-0"> 142 - <button 143 - onclick={() => (rateMovieModal.showModal = false)} 144 - class="rounded-full bg-base-800/50 p-1 px-2 text-xs text-base-50 hover:bg-base-800/70 hover:text-base-100" 145 - > 146 - change 147 - </button> 148 - </div> --> 149 - </div> 150 - {:else} 151 - <SearchCombobox /> 152 - {/if} 153 - 154 - <div class="mt-4"> 155 - <label for="comment" class="text-base-50 block text-xs font-medium">review</label> 156 - <div class="mt-2"> 157 - <textarea 158 - rows="4" 159 - name="comment" 160 - id="comment" 161 - bind:value={review} 162 - placeholder="write a review" 163 - class="outline-nonse border-base-800 bg-base-950 text-base-50 placeholder:text-base-400 focus:outline-accent-400 block w-full rounded-lg border px-3 py-1.5 text-base -outline-offset-1 focus:outline focus:outline-2 focus:-outline-offset-2 sm:text-sm/6" 164 - ></textarea> 165 - </div> 166 - </div> 167 - </div> 168 - <div class="mt-5 sm:mt-6"> 169 - <button 170 - onclick={async () => { 171 - if (rateMovieModal.selectedItem.editUri) { 172 - saveEdit(); 173 - } else { 174 - saveNew(); 175 - } 176 - }} 177 - type="button" 178 - disabled={sending || !rateMovieModal.selectedItem.name} 179 - class="border-accent-900 bg-accent-950/80 text-accent-300 hover:bg-accent-950 focus-visible:outline-accent-600 inline-flex w-full justify-center rounded-md border px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50" 180 - >{sending ? 'Sending...' : (rateMovieModal.selectedItem.editUri ? 'Update' : 'Review')}</button 181 - > 182 - </div> 183 - </div> 184 - </div> 185 - </div> 186 - </div> 187 - {/if}
-66
src/lib/Components/Rating.svelte
··· 1 - <script lang="ts"> 2 - import { cn } from '$lib/utils'; 3 - let { 4 - rating = $bindable(), 5 - size = 'size-5', 6 - canChange = false 7 - }: { rating: number; size?: string; canChange?: boolean } = $props(); 8 - 9 - let hoverRating = $state(rating); 10 - 11 - $effect(() => { 12 - hoverRating = rating; 13 - }); 14 - </script> 15 - 16 - <div class="flex items-center xl:col-span-1"> 17 - <div class="flex items-center"> 18 - {#if canChange} 19 - {#each Array.from({ length: 5 }).map((_, i) => i + 1) as i} 20 - <button onclick={() => (rating = i)}> 21 - <svg 22 - class={cn( 23 - size, 24 - 'shrink-0 stroke-base-500 text-base-600', 25 - i <= hoverRating && 'stroke-accent-400 text-accent-500' 26 - )} 27 - viewBox="0 0 24 24" 28 - fill="currentColor" 29 - aria-hidden="true" 30 - data-slot="icon" 31 - onmouseenter={() => (hoverRating = i)} 32 - onmouseleave={() => (hoverRating = rating)} 33 - > 34 - <path 35 - fill-rule="evenodd" 36 - 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" 37 - clip-rule="evenodd" 38 - /> 39 - </svg> 40 - 41 - <span class="sr-only">rate {i} stars</span> 42 - </button> 43 - {/each} 44 - {:else} 45 - {#each Array.from({ length: 5 }).map((_, i) => i + 1) as i} 46 - <svg 47 - class={cn( 48 - size, 49 - 'shrink-0 stroke-base-500 text-base-600', 50 - i <= rating && 'stroke-accent-400 text-accent-500' 51 - )} 52 - viewBox="0 0 24 24" 53 - fill="currentColor" 54 - aria-hidden="true" 55 - data-slot="icon" 56 - > 57 - <path 58 - fill-rule="evenodd" 59 - 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" 60 - clip-rule="evenodd" 61 - /> 62 - </svg> 63 - {/each} 64 - {/if} 65 - </div> 66 - </div>
-184
src/lib/Components/ReviewCard.svelte
··· 1 - <script lang="ts"> 2 - import type { MainRecord } from '$lib/db'; 3 - import { toast } from 'svelte-sonner'; 4 - import Rating from './Rating.svelte'; 5 - import RelativeTime from './relative-time/RelativeTime.svelte'; 6 - import { nameToId } from '$lib/utils'; 7 - import OptionButton from './OptionButton.svelte'; 8 - // import { crosspostModal } from '$lib/state.svelte'; 9 - 10 - let { data, showMovieDetails = true }: { data: MainRecord; showMovieDetails?: boolean } = 11 - $props(); 12 - 13 - let isLiked = $state(false); 14 - </script> 15 - 16 - <div class="relative w-full max-w-2xl p-4 backdrop-blur-sm"> 17 - <div class="flex max-w-full items-center gap-4 overflow-hidden"> 18 - {#if showMovieDetails} 19 - <a 20 - href={data.record.item.ref === 'tmdb:m' 21 - ? `/movie/${data.record.item.value}-${nameToId(data.record.metadata?.title ?? '')}` 22 - : `/tv/${data.record.item.value}-${nameToId(data.record.metadata?.title ?? '')}`} 23 - class="relative z-20 mr-4 aspect-[2/3] h-32 w-auto shrink-0 overflow-hidden rounded-md border border-base-800 bg-base-900/50 transition-opacity duration-75 hover:opacity-80 group-hover:opacity-75" 24 - > 25 - {#if data.record.metadata?.poster_path} 26 - <img 27 - src="https://image.tmdb.org/t/p/w154{data.record.metadata.poster_path}" 28 - alt="movie poster for {data.record.metadata.title}" 29 - class="poster size-full object-cover object-center lg:size-full" 30 - style:--name={`poster-${data.record.item.value}`} 31 - loading="lazy" 32 - /> 33 - {/if} 34 - <span class="sr-only">View {data.record.metadata?.title}</span> 35 - </a> 36 - {/if} 37 - 38 - <div class="flex flex-col gap-3"> 39 - <a 40 - href={`/user/${data.author.handle}`} 41 - class="z-20 flex flex-row items-center gap-4 overflow-hidden font-medium" 42 - > 43 - <div class="flex items-center gap-2"> 44 - {#if data.author.avatar} 45 - <img 46 - src={data.author.avatar.replace('avatar', 'avatar_thumbnail')} 47 - alt="user avatar" 48 - loading="lazy" 49 - class="size-6 rounded-full" 50 - /> 51 - {/if} 52 - <div class="truncate text-sm font-medium"> 53 - {data.author.displayName || data.author.handle} 54 - </div> 55 - </div> 56 - <div class="shrink-0 pr-2 text-sm text-base-400 font-normal"> 57 - <RelativeTime date={new Date(data.updatedAt)} locale="en-US" /> 58 - </div> 59 - </a> 60 - 61 - <div class="flex flex-col gap-3"> 62 - {#if showMovieDetails} 63 - <a 64 - href={data.record.item.ref === 'tmdb:m' 65 - ? `/movie/${data.record.item.value}-${nameToId(data.record.metadata?.title ?? '')}` 66 - : `/tv/${data.record.item.value}-${nameToId(data.record.metadata?.title ?? '')}`} 67 - class="title z-20 text-2xl font-bold hover:text-accent-200" 68 - style:--name={`title-${data.record.item.value}`} 69 - > 70 - {data.record.metadata?.title} 71 - </a> 72 - {/if} 73 - {#if data.record.rating?.value} 74 - <a href={`/review/${encodeURIComponent(data.uri)}`}> 75 - <Rating rating={data.record.rating?.value / 2} size={'size-6'} /> 76 - <span class="sr-only">View review</span> 77 - </a> 78 - {/if} 79 - 80 - <!-- <button 81 - onclick={() => { 82 - crosspostModal.show( 83 - data.uri, 84 - data.record.note?.value ?? '', 85 - (data.record.rating?.value ?? 0) / 2, 86 - data.record.metadata?.title ?? '' 87 - ); 88 - }} 89 - > 90 - crosspost 91 - </button> --> 92 - </div> 93 - </div> 94 - </div> 95 - 96 - {#if data.record.note?.value} 97 - <div class="mt-4 text-sm text-base-300"> 98 - {@html data.record.note?.value?.replace('\n', '<br /><br />')} 99 - </div> 100 - {/if} 101 - 102 - <div class="mt-6 flex justify-between gap-2 text-base-500 dark:text-base-400"> 103 - <!-- <button class="group inline-flex items-center gap-2 text-sm"> 104 - <svg 105 - xmlns="http://www.w3.org/2000/svg" 106 - fill="none" 107 - viewBox="0 0 24 24" 108 - stroke-width="1.5" 109 - stroke="currentColor" 110 - class="-m-1.5 size-7 rounded-full p-1.5 transition-all duration-100 group-hover:bg-amber-500/10 group-hover:text-amber-700 dark:group-hover:text-amber-400" 111 - > 112 - <path 113 - stroke-linecap="round" 114 - stroke-linejoin="round" 115 - d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" 116 - /> 117 - </svg> 118 - {#if data.post.replyCount} 119 - {numberToHumanReadable(data.post.replyCount)} 120 - {/if} 121 - </button> --> 122 - 123 - <button 124 - class="group inline-flex items-center gap-2 text-sm" 125 - onclick={async () => { 126 - if (isLiked) return; 127 - 128 - isLiked = true; 129 - 130 - try { 131 - const response = await fetch('/api/like', { 132 - method: 'POST', 133 - body: JSON.stringify({ cid: data.cid, uri: data.uri }) 134 - }); 135 - if (response.ok) { 136 - toast.success('Liked'); 137 - } else { 138 - toast.error('must be logged in to like'); 139 - isLiked = false; 140 - } 141 - } catch (e) { 142 - console.error(e); 143 - toast.error('must be logged in to like'); 144 - isLiked = false; 145 - } 146 - }} 147 - > 148 - {#if isLiked} 149 - <svg 150 - xmlns="http://www.w3.org/2000/svg" 151 - viewBox="0 0 24 24" 152 - fill="currentColor" 153 - class="-m-1.5 size-7 rounded-full p-1.5 transition-all duration-100 group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400" 154 - > 155 - <path 156 - d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" 157 - /> 158 - </svg> 159 - {:else} 160 - <svg 161 - xmlns="http://www.w3.org/2000/svg" 162 - fill="none" 163 - viewBox="0 0 24 24" 164 - stroke-width="1.5" 165 - stroke="currentColor" 166 - class="-m-1.5 size-7 rounded-full p-1.5 transition-all duration-100 group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400" 167 - > 168 - <path 169 - stroke-linecap="round" 170 - stroke-linejoin="round" 171 - d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" 172 - /> 173 - </svg> 174 - {/if} 175 - {(data.record.likes ?? 0) + (isLiked ? 1 : 0)} 176 - </button> 177 - 178 - <OptionButton data={data} /> 179 - </div> 180 - 181 - <!-- <a href={`/review/${encodeURIComponent(data.uri)}`}> 182 - <span class="absolute inset-0 z-10 hover:bg-accent-500/10"></span> 183 - </a> --> 184 - </div>
-17
src/lib/Components/ReviewList.svelte
··· 1 - <script lang="ts"> 2 - import type { MainRecord } from '$lib/db'; 3 - import { cn } from '$lib/utils'; 4 - import ReviewCard from './ReviewCard.svelte'; 5 - 6 - let { 7 - reviews, 8 - class: className, 9 - showMovieDetails 10 - }: { reviews: MainRecord[]; class?: string; showMovieDetails?: boolean } = $props(); 11 - </script> 12 - 13 - <div class={cn('flex w-full flex-col divide-y divide-base-900', className)}> 14 - {#each reviews as review} 15 - <ReviewCard data={review} {showMovieDetails} /> 16 - {/each} 17 - </div>
-166
src/lib/Components/SearchCombobox.svelte
··· 1 - <script lang="ts"> 2 - import { rateMovieModal } from '$lib/state.svelte'; 3 - import { createCombobox, melt } from '@melt-ui/svelte'; 4 - import { fly } from 'svelte/transition'; 5 - 6 - const { 7 - elements: { menu, input, option, label }, 8 - states: { open, inputValue, touchedInput }, 9 - helpers: { isSelected } 10 - } = createCombobox({ 11 - forceVisible: true 12 - }); 13 - 14 - let debounceTimer: ReturnType<typeof setTimeout>; 15 - 16 - const debounce = (callback: () => void) => { 17 - clearTimeout(debounceTimer); 18 - debounceTimer = setTimeout(callback, 500); 19 - }; 20 - 21 - let results: { 22 - movieId: number | null; 23 - showId: number | null; 24 - title: string; 25 - poster_path: string; 26 - media_type: string; 27 - kind: 'movie' | 'show'; 28 - }[] = []; 29 - 30 - let searching = false; 31 - 32 - function onInput() { 33 - if ($inputValue.length < 2) { 34 - results = []; 35 - } else { 36 - searching = true; 37 - debounce(async () => { 38 - if ($inputValue.length < 2) return; 39 - 40 - const response = await fetch(`/api/search?q=${$inputValue}`); 41 - const data = await response.json(); 42 - results = data 43 - .filter( 44 - (item: any) => 45 - (item.media_type === 'movie' || item.media_type === 'tv') && item.poster_path 46 - ) 47 - .map((item: any) => ({ 48 - movieId: item.media_type === 'movie' ? item.id : null, 49 - showId: item.media_type === 'tv' ? item.id : null, 50 - title: item.title || item.name, 51 - poster_path: item.poster_path, 52 - media_type: item.media_type, 53 - kind: item.media_type === 'movie' ? 'movie' : 'show' 54 - })); 55 - 56 - searching = false; 57 - }); 58 - } 59 - } 60 - </script> 61 - 62 - <div class="flex flex-col gap-1"> 63 - <!-- svelte-ignore a11y-label-has-associated-control - $label contains the 'for' attribute --> 64 - <!-- <label use:melt={$label}> 65 - <span class="text-sm font-medium text-accent-900">Find a movie or show:</span> 66 - </label> --> 67 - 68 - <div class="relative"> 69 - <input 70 - use:melt={$input} 71 - class="flex h-10 w-full items-center justify-between rounded-lg border-base-800 bg-black px-3 pr-12 text-base-50 72 - placeholder:text-base-400 focus:border-none focus:outline-none focus:ring-2 focus:ring-accent-500" 73 - placeholder="Find a movie or show" 74 - oninput={onInput} 75 - /> 76 - <div class="absolute right-2 top-1/2 z-10 -translate-y-1/2 text-accent-500"> 77 - {#if $open && $inputValue.length > 1} 78 - <svg 79 - xmlns="http://www.w3.org/2000/svg" 80 - fill="none" 81 - viewBox="0 0 24 24" 82 - stroke-width="1.5" 83 - stroke="currentColor" 84 - class="size-4" 85 - > 86 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" /> 87 - </svg> 88 - {:else} 89 - <svg 90 - xmlns="http://www.w3.org/2000/svg" 91 - fill="none" 92 - viewBox="0 0 24 24" 93 - stroke-width="1.5" 94 - stroke="currentColor" 95 - class="size-4" 96 - > 97 - <path 98 - stroke-linecap="round" 99 - stroke-linejoin="round" 100 - d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" 101 - /> 102 - </svg> 103 - {/if} 104 - </div> 105 - </div> 106 - </div> 107 - {#if $open && $inputValue.length > 1} 108 - <ul 109 - class=" z-50 flex max-h-[300px] flex-col overflow-hidden rounded-lg" 110 - use:melt={$menu} 111 - transition:fly={{ duration: 150, y: -5 }} 112 - > 113 - <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 114 - <div 115 - class="mx-2 flex max-h-full flex-col gap-0 divide-y divide-base-700/50 overflow-y-auto rounded-lg border border-base-700 bg-base-800 px-2 py-2 text-base-50" 116 - tabindex="0" 117 - > 118 - {#each results as item, index (index)} 119 - <li 120 - use:melt={$option({ 121 - value: item, 122 - label: item.title 123 - })} 124 - class="relative w-full cursor-pointer scroll-my-2 px-2 py-2 data-[highlighted]:text-accent-200" 125 - > 126 - <button 127 - onclick={() => { 128 - rateMovieModal.selectedItem = { 129 - movieId: item.movieId ?? undefined, 130 - showId: item.showId ?? undefined, 131 - kind: item.kind, 132 - name: item.title, 133 - posterPath: item.poster_path, 134 - currentRating: undefined, 135 - currentReview: undefined 136 - }; 137 - rateMovieModal.showModal = true; 138 - }} 139 - class="flex w-full items-center justify-start gap-2" 140 - > 141 - <div 142 - class="relative z-20 aspect-[2/3] h-12 w-auto shrink-0 overflow-hidden rounded-md border border-base-800 bg-base-900/50" 143 - > 144 - <img 145 - src="https://image.tmdb.org/t/p/w154{item.poster_path}" 146 - alt="movie poster for {item.title}" 147 - class="size-full object-cover object-center lg:size-full" 148 - /> 149 - </div> 150 - <span class="text-left text-sm font-medium">{item.title}</span> 151 - </button> 152 - </li> 153 - {:else} 154 - <li 155 - class="relative cursor-pointer rounded-md py-1 pl-4 pr-4 text-sm data-[highlighted]:text-accent-200" 156 - > 157 - {#if searching} 158 - Searching... 159 - {:else if $inputValue.length > 1} 160 - No results found 161 - {/if} 162 - </li> 163 - {/each} 164 - </div> 165 - </ul> 166 - {/if}
-30
src/lib/Components/Select.svelte
··· 1 - <script lang="ts"> 2 - import SelectItem from './SelectItem.svelte'; 3 - 4 - type Props = { 5 - items: string[]; 6 - active: string; 7 - onchange?: (active: string) => void; 8 - }; 9 - 10 - let { items, active = $bindable(), onchange }: Props = $props(); 11 - </script> 12 - 13 - <div class="inline-block"> 14 - <ul 15 - class="flex w-fit rounded-full px-3 text-sm font-medium text-base-800 shadow-lg shadow-base-800/5 ring-1 ring-base-900/5 backdrop-blur dark:bg-base-950 dark:text-base-200 dark:ring-white/10" 16 - > 17 - {#each items as item} 18 - <SelectItem 19 - onclick={() => { 20 - if (active == item) return; 21 - active = item; 22 - if (onchange) onchange(item); 23 - }} 24 - isActive={active == item} 25 - > 26 - {item} 27 - </SelectItem> 28 - {/each} 29 - </ul> 30 - </div>
-23
src/lib/Components/SelectItem.svelte
··· 1 - <script lang="ts"> 2 - import { fade } from 'svelte/transition'; 3 - 4 - const { onclick, isActive, children } = $props(); 5 - </script> 6 - 7 - <li> 8 - <button 9 - {onclick} 10 - class={'relative block px-3 py-2 text-xs transition duration-200 ' + 11 - (isActive 12 - ? 'text-accent-500 dark:text-accent-400' 13 - : 'cursor-pointer hover:text-accent-500 dark:hover:text-accent-400')} 14 - > 15 - {@render children()} 16 - {#if isActive} 17 - <span 18 - transition:fade 19 - class="absolute inset-x-1 -bottom-px h-px bg-gradient-to-r from-accent-500/0 via-accent-500/40 to-accent-500/0 dark:from-accent-400/0 dark:via-accent-400/40 dark:to-accent-400/0" 20 - ></span> 21 - {/if} 22 - </button> 23 - </li>
-177
src/lib/Components/Sidebar.svelte
··· 1 - <script lang="ts"> 2 - import { page } from '$app/stores'; 3 - import { BASE_PATH } from '$lib'; 4 - import { cn } from '$lib/utils'; 5 - import User from '$lib/Components/User.svelte'; 6 - import { rateMovieModal, showSidebar } from '$lib/state.svelte'; 7 - import { fade } from 'svelte/transition'; 8 - import { onNavigate } from '$app/navigation'; 9 - 10 - const menu = [ 11 - { 12 - icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 13 - <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> 14 - </svg>`, 15 - label: 'Home', 16 - href: '/' 17 - }, 18 - { 19 - icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 20 - <path stroke-linecap="round" stroke-linejoin="round" d="M12.75 3.03v.568c0 .334.148.65.405.864l1.068.89c.442.369.535 1.01.216 1.49l-.51.766a2.25 2.25 0 0 1-1.161.886l-.143.048a1.107 1.107 0 0 0-.57 1.664c.369.555.169 1.307-.427 1.605L9 13.125l.423 1.059a.956.956 0 0 1-1.652.928l-.679-.906a1.125 1.125 0 0 0-1.906.172L4.5 15.75l-.612.153M12.75 3.031a9 9 0 0 0-8.862 12.872M12.75 3.031a9 9 0 0 1 6.69 14.036m0 0-.177-.529A2.25 2.25 0 0 0 17.128 15H16.5l-.324-.324a1.453 1.453 0 0 0-2.328.377l-.036.073a1.586 1.586 0 0 1-.982.816l-.99.282c-.55.157-.894.702-.8 1.267l.073.438c.08.474.49.821.97.821.846 0 1.598.542 1.865 1.345l.215.643m5.276-3.67a9.012 9.012 0 0 1-5.276 3.67m0 0a9 9 0 0 1-10.275-4.835M15.75 9c0 .896-.393 1.7-1.016 2.25" /> 21 - </svg>`, 22 - label: 'Feed', 23 - href: '/feed' 24 - }, 25 - { 26 - icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 27 - <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /> 28 - </svg>`, 29 - label: 'Search', 30 - href: '/search' 31 - } 32 - ]; 33 - 34 - const userMenu = [ 35 - { 36 - icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 37 - <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 38 - </svg>`, 39 - label: 'New review', 40 - href: '/review/new', 41 - onclick: () => { 42 - showSidebar.value = false; 43 - rateMovieModal.showEmpty(); 44 - } 45 - }, 46 - { 47 - icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 48 - <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /> 49 - <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> 50 - </svg>`, 51 - label: 'Settings', 52 - href: '/settings' 53 - } 54 - ]; 55 - 56 - let { user } = $props(); 57 - 58 - onNavigate(() => { 59 - showSidebar.value = false; 60 - }); 61 - </script> 62 - 63 - {#key $page.url.pathname} 64 - <div 65 - class={cn( 66 - 'sidebar fixed bottom-2 left-0 top-2 z-50 w-[4.5rem] py-2 transition-transform duration-300', 67 - showSidebar.value ? 'translate-x-0' : '-translate-x-64 md:translate-x-0' 68 - )} 69 - > 70 - <div class="flex h-full flex-col items-center justify-end space-y-1 pb-2 md:justify-between"> 71 - <ul class="flex flex-col items-center space-y-2"> 72 - {#each menu as item} 73 - <li class="group relative size-12"> 74 - <a 75 - href={item.href} 76 - class={cn( 77 - 'group flex items-center gap-x-3 rounded-md p-3 text-sm/6 font-semibold', 78 - $page.url.pathname === item.href 79 - ? 'bg-accent-950/10 text-accent-400' 80 - : 'text-base-200 transition-colors duration-100 hover:bg-accent-950/20 hover:text-accent-400' 81 - )} 82 - > 83 - {@html item.icon} 84 - <span 85 - class="pointer-events-none absolute left-14 rounded-lg transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100 md:bg-accent-950/20 md:px-3 md:py-2 md:opacity-0" 86 - >{item.label}</span 87 - > 88 - 89 - {#if showSidebar.value} 90 - <span class="absolute inset-0 block h-full w-screen md:hidden"></span> 91 - {/if} 92 - </a> 93 - </li> 94 - {/each} 95 - </ul> 96 - <ul class="flex flex-col items-center space-y-2"> 97 - {#if user} 98 - {#each userMenu as item} 99 - <li class="group relative"> 100 - <a 101 - href={item.href} 102 - class={'group flex items-center gap-x-3 rounded-md p-3 text-sm/6 font-semibold text-base-200 transition-colors duration-100 hover:bg-accent-950/20 hover:text-accent-400'} 103 - onclick={(event) => { 104 - if (item.onclick) { 105 - event.preventDefault(); 106 - item.onclick(); 107 - } 108 - }} 109 - > 110 - {@html item.icon} 111 - <span 112 - class="pointer-events-none absolute left-14 whitespace-nowrap rounded-lg backdrop-blur-md transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100 md:bg-accent-950/20 md:px-3 md:py-2 md:opacity-0" 113 - >{item.label}</span 114 - > 115 - 116 - {#if showSidebar.value} 117 - <span class="absolute inset-0 block h-full w-screen md:hidden"></span> 118 - {/if} 119 - </a> 120 - </li> 121 - {/each} 122 - {/if} 123 - 124 - <User {user} /> 125 - </ul> 126 - </div> 127 - </div> 128 - {/key} 129 - 130 - {#if showSidebar.value} 131 - <button 132 - transition:fade 133 - class="fixed inset-0 z-40 bg-base-950/90 backdrop-blur-sm md:hidden" 134 - onclick={() => showSidebar.toggle()} 135 - > 136 - <span class="sr-only">Close Menu</span> 137 - </button> 138 - 139 - <button 140 - transition:fade 141 - onclick={() => showSidebar.toggle()} 142 - class="fixed bottom-6 right-4 z-50 md:hidden" 143 - > 144 - <span class="sr-only">close Menu</span> 145 - 146 - <svg 147 - xmlns="http://www.w3.org/2000/svg" 148 - fill="none" 149 - viewBox="0 0 24 24" 150 - stroke-width="1.5" 151 - stroke="currentColor" 152 - class="size-6" 153 - > 154 - <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 155 - </svg> 156 - </button> 157 - {:else} 158 - <button 159 - class="fixed bottom-2 left-2 z-50 rounded-lg border border-base-800 bg-base-900/75 p-2 backdrop-blur-sm md:hidden" 160 - onclick={() => showSidebar.toggle()} 161 - > 162 - <span class="sr-only">Open Menu</span> 163 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 164 - <path 165 - fill-rule="evenodd" 166 - d="M3 9a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 9Zm0 6.75a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75a.75.75 0 0 1-.75-.75Z" 167 - clip-rule="evenodd" 168 - /> 169 - </svg> 170 - </button> 171 - {/if} 172 - 173 - <style> 174 - .sidebar { 175 - view-transition-name: sidebar; 176 - } 177 - </style>
-61
src/lib/Components/User.svelte
··· 1 - <script lang="ts"> 2 - import { showLoginModal } from '$lib/state.svelte'; 3 - import { cn } from '$lib/utils'; 4 - 5 - type Props = { 6 - user?: { 7 - avatar?: string; 8 - handle?: string; 9 - }; 10 - }; 11 - 12 - const { user }: Props = $props(); 13 - </script> 14 - 15 - {#snippet renderUser()} 16 - <span class="sr-only">Open user menu</span> 17 - <div 18 - class={cn( 19 - 'size-8 overflow-hidden rounded-full border border-base-400/50 bg-base-100 transition-colors duration-100 hover:border-[1.5px] hover:border-accent-400 dark:border-base-700 dark:bg-base-800', 20 - false 21 - ? 'border-accent-300/50 bg-accent-100/50 dark:border-accent-500/50 dark:bg-accent-800/50' 22 - : '' 23 - )} 24 - > 25 - {#if user?.avatar} 26 - <img loading="lazy" class="h-full w-full object-cover" src={user.avatar} alt="" /> 27 - {:else} 28 - <svg 29 - class={cn( 30 - 'size-full text-base-300 dark:text-base-600', 31 - user ? 'text-accent-300 dark:text-accent-500' : '' 32 - )} 33 - fill="currentColor" 34 - viewBox="0 0 24 24" 35 - > 36 - <path 37 - d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" 38 - /> 39 - </svg> 40 - {/if} 41 - </div> 42 - {/snippet} 43 - 44 - <li class="relative pt-4"> 45 - {#if user} 46 - <a href="/user/{user.handle}"> 47 - {@render renderUser()} 48 - </a> 49 - {:else} 50 - <button 51 - type="button" 52 - class="flex cursor-pointer items-center hover:opacity-80" 53 - id="user-menu-button" 54 - aria-expanded="false" 55 - aria-haspopup="true" 56 - onclick={() => showLoginModal.toggle()} 57 - > 58 - {@render renderUser()} 59 - </button> 60 - {/if} 61 - </li>
-133
src/lib/Components/VideoPlayer.svelte
··· 1 - <script lang="ts"> 2 - import { cn } from '../utils'; 3 - import Plyr from 'plyr'; 4 - import { videoPlayer } from '$lib/state.svelte'; 5 - 6 - const { class: className }: { class?: string } = $props(); 7 - 8 - $effect(() => { 9 - const player = new Plyr('.js-player', { 10 - settings: ['captions', 'quality', 'loop', 'speed'], 11 - controls: [ 12 - 'play-large', 13 - 'play', 14 - 'progress', 15 - 'current-time', 16 - 'volume', 17 - 'settings', 18 - 'download', 19 - 'fullscreen' 20 - ] 21 - }); 22 - 23 - // set the video player to the id 24 - if (videoPlayer.id) { 25 - player.source = { 26 - type: 'video', 27 - sources: [ 28 - { 29 - src: videoPlayer.id, 30 - type: 'video/youtube' 31 - } 32 - ] 33 - }; 34 - } 35 - 36 - // when loaded play the video and go fullscreen 37 - player.on('ready', () => { 38 - player.play(); 39 - //player.fullscreen.enter(); 40 - }); 41 - 42 - return () => { 43 - player.destroy(); 44 - }; 45 - }); 46 - 47 - let glow = 50; 48 - </script> 49 - 50 - <svelte:head> 51 - {#if videoPlayer.showing && videoPlayer.id} 52 - <link rel="stylesheet" href="/plyr.css" /> 53 - {/if} 54 - </svelte:head> 55 - 56 - <svelte:window 57 - onkeydown={(e) => { 58 - if (e.key === 'Escape' && videoPlayer.showing) { 59 - videoPlayer.hide(); 60 - } 61 - }} 62 - /> 63 - 64 - {#key videoPlayer.id} 65 - {#if videoPlayer.showing && videoPlayer.id} 66 - <div class="fixed inset-0 z-[100] flex h-screen w-screen items-center justify-center"> 67 - <button 68 - onclick={() => videoPlayer.hide()} 69 - class="absolute inset-0 bg-black/70 backdrop-blur-sm" 70 - > 71 - <span class="sr-only">Close</span> 72 - </button> 73 - 74 - <div 75 - class={cn( 76 - 'aspect-video relative mx-4 max-h-screen w-full overflow-hidden rounded-xl border border-black bg-white object-cover dark:border-white/10 dark:bg-white/5 sm:mx-20', 77 - className 78 - )} 79 - style="filter: url(#blur); width: 100%;" 80 - > 81 - <div class=""> 82 - <div 83 - id="player" 84 - class="h-full w-full overflow-hidden rounded-xl object-cover font-semibold text-black dark:text-white" 85 - > 86 - <div 87 - class="js-player plyr__video-embed" 88 - id="player" 89 - data-plyr-provider="youtube" 90 - data-plyr-embed-id={videoPlayer.id} 91 - ></div> 92 - </div> 93 - </div> 94 - </div> 95 - 96 - <button 97 - onclick={() => { 98 - videoPlayer.hide(); 99 - }} 100 - class="absolute right-2 top-2 z-20 rounded-full border border-white/10 bg-white/5 p-2 backdrop-blur-sm" 101 - > 102 - <svg 103 - xmlns="http://www.w3.org/2000/svg" 104 - viewBox="0 0 24 24" 105 - fill="currentColor" 106 - class="size-6" 107 - > 108 - <path 109 - fill-rule="evenodd" 110 - d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" 111 - clip-rule="evenodd" 112 - /> 113 - </svg> 114 - 115 - <span class="sr-only">Close</span> 116 - </button> 117 - </div> 118 - {/if} 119 - {/key} 120 - 121 - <svg width="0" height="0"> 122 - <filter id="blur" y="-50%" x="-50%" width="300%" height="300%"> 123 - <feGaussianBlur in="SourceGraphic" stdDeviation={glow} result="blurred" /> 124 - <feColorMatrix type="saturate" in="blurred" values="3" /> 125 - <feComposite in="SourceGraphic" operator="over" /> 126 - </filter> 127 - </svg> 128 - 129 - <style> 130 - * { 131 - --plyr-color-main: #38bdf8; 132 - } 133 - </style>
+31 -51
src/lib/server/movies.ts
··· 1 1 import { env } from '$env/dynamic/private'; 2 - 3 - import type { Agent } from '@atproto/api'; 2 + import type { Item } from '$lib/types'; 4 3 5 4 export type Kind = 'movie' | 'tv'; 6 5 7 - export async function searchMulti(query: string) { 6 + export function ref(id: number, kind: Kind) { 7 + return `tmdb:${kind === 'movie' ? 'm' : 's'}-${id}`; 8 + } 9 + 10 + export async function searchMulti(query: string): Promise<Item[]> { 8 11 const apiUrl = `https://api.themoviedb.org/3/search/multi?query=${query}&include_adult=false&language=en-US&page=1`; 9 12 const options = { 10 13 method: 'GET', ··· 17 20 const response = await fetch(apiUrl, options); 18 21 const data = await response.json(); 19 22 20 - return data.results; 23 + return data.results 24 + .filter( 25 + (result: { poster_path: string; media_type: string }) => 26 + result.poster_path !== null && result.media_type !== 'person' 27 + ) 28 + .map((result: { id: number; media_type: string; title?: string; name?: string }) => { 29 + return { 30 + ref: ref(result.id, result.media_type as Kind), 31 + title: result.title ?? result.name, 32 + ...result 33 + }; 34 + }) as Item[]; 21 35 } 22 36 23 37 export async function getDetails(id: number, kind: Kind) { ··· 33 47 const response = await fetch(url, options); 34 48 const data = await response.json(); 35 49 36 - return data; 50 + return { 51 + ...data, 52 + ref: ref(data.id, kind) 53 + }; 37 54 } 38 55 39 56 export async function getTrailer(id: number, kind: Kind): Promise<string | null> { ··· 50 67 const data = await response.json(); 51 68 52 69 let trailer = data.results?.find( 53 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 54 - (video: any) => video.site === 'YouTube' && video.type === 'Trailer' && video.official 70 + (video: { site: string; type: string; official: boolean }) => 71 + video.site === 'YouTube' && video.type === 'Trailer' && video.official 55 72 ); 56 73 57 74 if (!trailer) { 58 75 trailer = data.results?.find( 59 - // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 - (video: any) => video.site === 'YouTube' && video.type === 'Trailer' 76 + (video: { site: string; type: string }) => 77 + video.site === 'YouTube' && video.type === 'Trailer' 61 78 ); 62 79 } 63 80 64 81 return trailer?.key ?? null; 65 82 } 66 83 67 - export async function getRecommendations(id: number, kind: Kind) { 84 + export async function getRecommendations(id: number, kind: Kind): Promise<Item[]> { 68 85 const url = `https://api.themoviedb.org/3/${kind}/${id}/recommendations?language=en-US`; 69 86 const options = { 70 87 method: 'GET', ··· 77 94 const response = await fetch(url, options); 78 95 const data = await response.json(); 79 96 80 - return data.results; 97 + return data.results.map((item: { id: number }) => ({ 98 + ...item, 99 + ref: ref(item.id, kind) 100 + })) as Item[]; 81 101 } 82 102 83 103 export async function getWatchProviders(id: number, kind: Kind) { ··· 94 114 const data = await response.json(); 95 115 96 116 return data.results; 97 - } 98 - 99 - export async function getWatchedMoviesIdsFromPDS(agent: Agent, did: string) { 100 - const allRecords = await agent.com.atproto.repo.listRecords({ 101 - repo: did, 102 - collection: 'my.skylights.rel' 103 - }); 104 - 105 - const movies = allRecords.data.records.filter((record) => record.value.item.ref === 'tmdb:m'); 106 - 107 - return new Map( 108 - movies.map((movie) => [ 109 - parseInt(movie.value.item.value ?? '0'), 110 - { 111 - rating: movie.value.rating.value / 2, 112 - ratingText: movie.value.note?.value, 113 - updatedAt: movie.value.note?.updatedAt ?? movie.value.rating.createdAt 114 - } 115 - ]) 116 - ); 117 - } 118 - 119 - export async function getWatchedShowsIdsFromPDS(agent: Agent, did: string) { 120 - const allRecords = await agent.com.atproto.repo.listRecords({ 121 - repo: did, 122 - collection: 'my.skylights.rel' 123 - }); 124 - 125 - const shows = allRecords.data.records.filter((record) => record.value.item.ref === 'tmdb:s'); 126 - 127 - return new Map( 128 - shows.map((show) => [ 129 - parseInt(show.value.item.value ?? '0'), 130 - { 131 - rating: show.value.rating.value / 2, 132 - ratingText: show.value.note?.value, 133 - updatedAt: show.value.note?.updatedAt ?? show.value.rating.createdAt 134 - } 135 - ]) 136 - ); 137 117 } 138 118 139 119 export async function getCast(id: number, kind: Kind) {
+83
src/lib/state/modals.svelte.ts
··· 1 + import type { Item } from '$lib/types'; 2 + 3 + export const showLoginModal = $state({ 4 + value: false, 5 + toggle: () => { 6 + showLoginModal.value = !showLoginModal.value; 7 + } 8 + }); 9 + 10 + export const showSidebar = $state({ 11 + value: false, 12 + toggle: () => { 13 + showSidebar.value = !showSidebar.value; 14 + } 15 + }); 16 + 17 + type RateMovieModalItem = Item & { 18 + currentRating: number | undefined; 19 + currentReview: string | undefined; 20 + 21 + editUri?: string; 22 + }; 23 + 24 + export const rateMovieModal: { 25 + showModal: boolean; 26 + 27 + selectedItem?: RateMovieModalItem; 28 + 29 + show: (item: RateMovieModalItem) => void; 30 + hide: () => void; 31 + showEmpty: () => void; 32 + } = $state({ 33 + showModal: false, 34 + 35 + selectedItem: undefined, 36 + 37 + show: (item: RateMovieModalItem) => { 38 + rateMovieModal.selectedItem = item; 39 + rateMovieModal.showModal = true; 40 + }, 41 + 42 + hide: () => { 43 + rateMovieModal.showModal = false; 44 + }, 45 + 46 + showEmpty: () => { 47 + rateMovieModal.selectedItem = undefined; 48 + rateMovieModal.showModal = true; 49 + } 50 + }); 51 + 52 + export const crosspostModal: { 53 + showModal: boolean; 54 + uri: string | undefined; 55 + review: string | undefined; 56 + rating: number | undefined; 57 + title: string | undefined; 58 + 59 + show: (uri: string, review: string, rating: number, title: string) => void; 60 + hide: () => void; 61 + } = $state({ 62 + showModal: false, 63 + uri: undefined, 64 + review: undefined, 65 + rating: undefined, 66 + title: undefined, 67 + 68 + show: (uri: string, review: string, rating: number, title: string) => { 69 + crosspostModal.uri = uri; 70 + crosspostModal.review = review; 71 + crosspostModal.rating = rating; 72 + crosspostModal.title = title; 73 + crosspostModal.showModal = true; 74 + }, 75 + 76 + hide: () => { 77 + crosspostModal.showModal = false; 78 + crosspostModal.uri = undefined; 79 + crosspostModal.review = undefined; 80 + crosspostModal.rating = undefined; 81 + crosspostModal.title = undefined; 82 + } 83 + });
+99
src/lib/state/user.svelte.ts
··· 1 + import type { Item, Ref } from '$lib/types'; 2 + 3 + const CACHE_EXPIRATION_TIME = 10 * 60 * 1000; // 10 minutes 4 + const CACHE_VERSION = '2'; 5 + 6 + export const settings: { 7 + streamingRegion: string | undefined; 8 + crosspostEnabled: boolean | undefined; 9 + } = $state({ 10 + streamingRegion: undefined, 11 + crosspostEnabled: undefined 12 + }); 13 + 14 + export const user: { 15 + displayName: string | undefined; 16 + handle: string | undefined; 17 + avatar: string | undefined; 18 + did: string | undefined; 19 + } = $state({ 20 + displayName: undefined, 21 + handle: undefined, 22 + avatar: undefined, 23 + did: undefined 24 + }); 25 + 26 + export const watchedItems = $state({ 27 + ratedItems: new Map<Item['ref'], { rating: number; ratingText?: string; updatedAt: string }>(), 28 + 29 + hasRated: (item: { ref: Ref }) => { 30 + return watchedItems.ratedItems.has(item.ref); 31 + }, 32 + 33 + addRated: (item: Item & { rating: number; ratingText?: string }) => { 34 + watchedItems.ratedItems.set(item.ref, { 35 + rating: item.rating, 36 + ratingText: item.ratingText, 37 + updatedAt: new Date().toISOString() 38 + }); 39 + 40 + watchedItems.saveToLocalStorage(); 41 + }, 42 + 43 + getRating: (item: { ref: Ref }) => { 44 + return watchedItems.ratedItems.get(item.ref); 45 + }, 46 + 47 + load: async () => { 48 + if (!user.did) return; 49 + 50 + const version = localStorage.getItem('skywatched-version'); 51 + if (!version || version !== CACHE_VERSION) { 52 + localStorage.clear(); 53 + // set version 54 + localStorage.setItem('skywatched-version', CACHE_VERSION); 55 + } 56 + 57 + const cachedRatedItems = localStorage.getItem(`ratedItems-${user.did}`); 58 + const lastUpdate = localStorage.getItem(`ratedItems-${user.did}-lastUpdate`); 59 + if ( 60 + cachedRatedItems && 61 + lastUpdate && 62 + new Date(lastUpdate).getTime() + CACHE_EXPIRATION_TIME > Date.now() 63 + ) { 64 + watchedItems.ratedItems = new Map(JSON.parse(cachedRatedItems).value); 65 + console.log('using cached rated items, last update:', lastUpdate); 66 + } else { 67 + const response = await fetch(`/api/rated?did=${user.did}`); 68 + const items = await response.json(); 69 + 70 + watchedItems.ratedItems = new Map(items); 71 + console.log('using api rated items'); 72 + watchedItems.saveToLocalStorage(); 73 + } 74 + console.log('rated items:', watchedItems.ratedItems); 75 + }, 76 + 77 + saveToLocalStorage: () => { 78 + if (!user.did) return; 79 + 80 + localStorage.setItem(`ratedItems-${user.did}-lastUpdate`, new Date().toISOString()); 81 + localStorage.setItem( 82 + `ratedItems-${user.did}`, 83 + JSON.stringify(watchedItems.ratedItems, replacer) 84 + ); 85 + 86 + console.log('saved to local storage', watchedItems.ratedItems, new Date().toISOString()); 87 + } 88 + }); 89 + 90 + function replacer(key: string, value: unknown) { 91 + if (value instanceof Map) { 92 + return { 93 + dataType: 'Map', 94 + value: Array.from(value.entries()) 95 + }; 96 + } else { 97 + return value; 98 + } 99 + }
+20
src/lib/state/video.svelte.ts
··· 1 + export const videoPlayer: { 2 + showing: boolean; 3 + id: string | undefined; 4 + 5 + show: (id: string) => void; 6 + hide: () => void; 7 + } = $state({ 8 + showing: false, 9 + id: undefined, 10 + 11 + show: (id: string) => { 12 + videoPlayer.id = id; 13 + videoPlayer.showing = true; 14 + }, 15 + 16 + hide: () => { 17 + videoPlayer.id = undefined; 18 + videoPlayer.showing = false; 19 + } 20 + });
+4 -4
src/routes/feed/+page.svelte
··· 1 1 <script lang="ts"> 2 - import Container from '$lib/Components/Container.svelte'; 3 - import { onMount } from 'svelte'; 4 2 import { type PageData } from './$types'; 5 - import ReviewList from '$lib/Components/ReviewList.svelte'; 6 - import BaseHeadTags from '$lib/Components/BaseHeadTags.svelte'; 3 + 4 + import Container from '$lib/Components/Layout/Container.svelte'; 5 + import ReviewList from '$lib/Components/Items/ReviewList.svelte'; 6 + import BaseHeadTags from '$lib/Components/Layout/BaseHeadTags.svelte'; 7 7 8 8 let { data }: { data: PageData } = $props(); 9 9 </script>
-14
src/routes/login/+page.server.ts
··· 1 - import { redirect } from '@sveltejs/kit'; 2 - 3 - import type { Actions, PageServerLoad } from './$types'; 4 - 5 - export const load: PageServerLoad = async (event) => { 6 - if (event.locals.user) { 7 - return redirect(302, '/user/' + event.locals.user.handle); 8 - } 9 - return {}; 10 - }; 11 - 12 - export const actions: Actions = { 13 - login: async (event) => {} 14 - };
-62
src/routes/login/+page.svelte
··· 1 - <script lang="ts"> 2 - import type { ActionData } from './$types'; 3 - import Logo from '$lib/Components/Logo.svelte'; 4 - 5 - let { form }: { form: ActionData } = $props(); 6 - </script> 7 - 8 - <div class="flex min-h-screen flex-col items-center justify-center px-6 py-12 lg:px-8"> 9 - <div class="sm:mx-auto sm:w-full sm:max-w-sm"> 10 - <Logo class="mx-auto size-10" /> 11 - <h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-white">login/register</h2> 12 - </div> 13 - 14 - <div class="mt-10 w-full sm:mx-auto sm:max-w-sm"> 15 - <form class="space-y-6" action="?/login" method="POST"> 16 - <p class="min-h-6 font-semibold lowercase text-red-500">{form?.message ?? ''}</p> 17 - <div> 18 - <label for="username" class="block text-sm/6 font-medium text-white">username</label> 19 - <div class="mt-2"> 20 - <input 21 - id="username" 22 - name="username" 23 - type="text" 24 - autocomplete="username" 25 - required 26 - class="block w-full rounded-xl border-0 bg-white/5 py-1.5 text-white shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-accent-500 sm:text-sm/6" 27 - /> 28 - </div> 29 - </div> 30 - 31 - <div> 32 - <div class="flex items-center justify-between"> 33 - <label for="password" class="block text-sm/6 font-medium text-white">password</label> 34 - </div> 35 - <div class="mt-2"> 36 - <input 37 - id="password" 38 - name="password" 39 - type="password" 40 - autocomplete="current-password" 41 - required 42 - class="block w-full rounded-xl border-0 bg-white/5 py-1.5 text-white shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-accent-500 sm:text-sm/6" 43 - /> 44 - </div> 45 - </div> 46 - 47 - <div> 48 - <button 49 - type="submit" 50 - class="flex w-full justify-center rounded-xl bg-accent-500 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-accent-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500" 51 - >login</button 52 - > 53 - <button 54 - formaction="?/register" 55 - type="submit" 56 - class="mt-4 flex w-full justify-center rounded-xl bg-white/10 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-white/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500" 57 - >register</button 58 - > 59 - </div> 60 - </form> 61 - </div> 62 - </div>
+1 -11
src/routes/search/+page.server.ts
··· 1 1 import { searchMulti } from '$lib/server/movies'; 2 - import { redirect } from '@sveltejs/kit'; 3 2 4 3 /** @type {import('./$types').PageServerLoad} */ 5 4 export async function load(event) { 6 5 const query = event.url.searchParams.get('query'); 7 6 8 7 if (query) { 9 - const results = (await searchMulti(query)) 10 - .map((result) => { 11 - if (result.media_type === 'movie') { 12 - return { ...result, movieId: result.id }; 13 - } else if (result.media_type === 'tv') { 14 - return { ...result, showId: result.id }; 15 - } 16 - return null; 17 - }) 18 - .filter((result) => result !== null && result.poster_path !== null); 8 + const results = await searchMulti(query); 19 9 20 10 return { results, query }; 21 11 }
+3 -3
src/routes/search/+page.svelte
··· 1 1 <script lang="ts"> 2 - import ItemsGrid from '$lib/Components/ItemsGrid.svelte'; 3 - import Container from '$lib/Components/Container.svelte'; 4 - import BaseHeadTags from '$lib/Components/BaseHeadTags.svelte'; 2 + import ItemsGrid from '$lib/Components/Items/ItemsGrid.svelte'; 3 + import Container from '$lib/Components/Layout/Container.svelte'; 4 + import BaseHeadTags from '$lib/Components/Layout/BaseHeadTags.svelte'; 5 5 6 6 import { type PageData } from './$types'; 7 7 let { data }: { data: PageData } = $props();
+1 -2
src/routes/settings/+page.svelte
··· 4 4 5 5 import availableRegions from './available_regions.json'; 6 6 import { onMount } from 'svelte'; 7 - import { settings, user } from '$lib/state.svelte'; 8 - import Note from '$lib/Components/Note.svelte'; 7 + import Note from '$lib/Components/Items/Note.svelte'; 9 8 10 9 export let data; 11 10
+85
src/lib/Components/Items/ItemCard.svelte
··· 1 + <script lang="ts"> 2 + import { rateMovieModal } from '$lib/state/modals.svelte'; 3 + import { watchedItems } from '$lib/state/user.svelte'; 4 + import type { Item } from '$lib/types'; 5 + import { cn, nameToId } from '$lib/utils'; 6 + import Rating from './Rating.svelte'; 7 + 8 + const { 9 + item, 10 + showMark 11 + }: { 12 + item: Item; 13 + showMark?: boolean; 14 + } = $props(); 15 + </script> 16 + 17 + <div class="group relative"> 18 + <div 19 + class="pointer-events-none relative z-20 aspect-[2/3] h-auto min-h-44 w-full overflow-hidden rounded-md border border-base-800 bg-base-900/50 transition-opacity duration-75 group-hover:opacity-75 sm:min-h-44" 20 + > 21 + {#if item.poster_path} 22 + <img 23 + src="https://image.tmdb.org/t/p/w342{item.poster_path}" 24 + alt="movie poster for {item.title}" 25 + class="poster size-full object-cover object-center lg:size-full" 26 + loading="lazy" 27 + /> 28 + {/if} 29 + 30 + {#if showMark} 31 + {#if watchedItems.hasRated(item)} 32 + <div 33 + class="absolute inset-0 z-10 bg-gradient-to-b from-transparent via-black/30 to-black/70" 34 + ></div> 35 + <div class="absolute bottom-2 left-0 right-0 z-10 flex justify-center"> 36 + <Rating rating={watchedItems.getRating(item)?.rating ?? 0} /> 37 + </div> 38 + {:else} 39 + <button 40 + class={cn( 41 + 'pointer-events-auto absolute bottom-2 right-2 z-10 rounded-full border border-base-50/20 bg-black/30 p-2 text-base-50 opacity-100 backdrop-blur-sm transition-opacity duration-300 ease-in-out group-hover:opacity-100 sm:opacity-0', 42 + watchedItems.hasRated(item) 43 + ? 'border-green-500/20 bg-green-900/60 text-green-500 sm:opacity-100' 44 + : 'hover:border-accent-800/50 hover:bg-accent-900/50 hover:text-accent-400 hover:duration-0' 45 + )} 46 + onclick={() => { 47 + rateMovieModal.show({ 48 + ...item, 49 + currentRating: 0, 50 + currentReview: '', 51 + poster_path: item.poster_path 52 + }); 53 + }} 54 + > 55 + <span class="sr-only">rate</span> 56 + 57 + <svg 58 + xmlns="http://www.w3.org/2000/svg" 59 + fill="none" 60 + viewBox="0 0 24 24" 61 + stroke-width="2.5" 62 + stroke="currentColor" 63 + class="size-5" 64 + > 65 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 66 + </svg> 67 + </button> 68 + {/if} 69 + {/if} 70 + </div> 71 + <div class="mt-2 flex justify-between"> 72 + <h3 class="sm:text-md text-sm font-medium text-base-50"> 73 + <a 74 + href="/{item.media_type}/{item.id}-{nameToId( 75 + item.title ?? '' 76 + )}" 77 + > 78 + <span aria-hidden="true" class="absolute inset-0"></span> 79 + <div class="line-clamp-2 max-w-full"> 80 + {item.title} 81 + </div> 82 + </a> 83 + </h3> 84 + </div> 85 + </div>
+26
src/lib/Components/Items/ItemsGrid.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { cn } from '$lib/utils'; 4 + import ItemCard from './ItemCard.svelte'; 5 + 6 + const { 7 + items, 8 + class: className, 9 + showMark 10 + }: { 11 + items: Item[]; 12 + class?: string; 13 + showMark?: boolean; 14 + } = $props(); 15 + </script> 16 + 17 + <div 18 + class={cn( 19 + 'mt-6 grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 xl:gap-x-8', 20 + className 21 + )} 22 + > 23 + {#each items as item} 24 + <ItemCard {item} {showMark} /> 25 + {/each} 26 + </div>
+21
src/lib/Components/Items/ItemsList.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { cn } from '$lib/utils'; 4 + import ItemCard from './ItemCard.svelte'; 5 + 6 + const { 7 + items, 8 + class: className, 9 + showMark 10 + }: { 11 + items: Item[]; 12 + class?: string; 13 + showMark?: boolean; 14 + } = $props(); 15 + </script> 16 + 17 + <div class={cn('flex gap-x-6 overflow-x-auto', className)}> 18 + {#each items as item} 19 + <ItemCard {item} {showMark} /> 20 + {/each} 21 + </div>
+34
src/lib/Components/Items/Note.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '$lib/utils'; 3 + 4 + let { 5 + children, 6 + class: className 7 + }: { 8 + children: () => any; 9 + class?: string; 10 + } = $props(); 11 + </script> 12 + 13 + <div class={cn('rounded-md border border-sky-500/20 bg-sky-500/10 p-4', className)}> 14 + <div class="flex"> 15 + <div class="shrink-0"> 16 + <svg 17 + class="size-5 text-sky-500" 18 + viewBox="0 0 20 20" 19 + fill="currentColor" 20 + aria-hidden="true" 21 + data-slot="icon" 22 + > 23 + <path 24 + fill-rule="evenodd" 25 + d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" 26 + clip-rule="evenodd" 27 + /> 28 + </svg> 29 + </div> 30 + <div class="ml-3 flex-1 md:flex md:justify-between"> 31 + <p class="text-sm text-sky-400">{@render children()}</p> 32 + </div> 33 + </div> 34 + </div>
+66
src/lib/Components/Items/Rating.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '$lib/utils'; 3 + let { 4 + rating = $bindable(), 5 + size = 'size-5', 6 + canChange = false 7 + }: { rating: number; size?: string; canChange?: boolean } = $props(); 8 + 9 + let hoverRating = $state(rating); 10 + 11 + $effect(() => { 12 + hoverRating = rating; 13 + }); 14 + </script> 15 + 16 + <div class="flex items-center xl:col-span-1"> 17 + <div class="flex items-center"> 18 + {#if canChange} 19 + {#each Array.from({ length: 5 }).map((_, i) => i + 1) as i} 20 + <button onclick={() => (rating = i)}> 21 + <svg 22 + class={cn( 23 + size, 24 + 'shrink-0 stroke-base-500 text-base-600', 25 + i <= hoverRating && 'stroke-accent-400 text-accent-500' 26 + )} 27 + viewBox="0 0 24 24" 28 + fill="currentColor" 29 + aria-hidden="true" 30 + data-slot="icon" 31 + onmouseenter={() => (hoverRating = i)} 32 + onmouseleave={() => (hoverRating = rating)} 33 + > 34 + <path 35 + fill-rule="evenodd" 36 + 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" 37 + clip-rule="evenodd" 38 + /> 39 + </svg> 40 + 41 + <span class="sr-only">rate {i} stars</span> 42 + </button> 43 + {/each} 44 + {:else} 45 + {#each Array.from({ length: 5 }).map((_, i) => i + 1) as i} 46 + <svg 47 + class={cn( 48 + size, 49 + 'shrink-0 stroke-base-500 text-base-600', 50 + i <= rating && 'stroke-accent-400 text-accent-500' 51 + )} 52 + viewBox="0 0 24 24" 53 + fill="currentColor" 54 + aria-hidden="true" 55 + data-slot="icon" 56 + > 57 + <path 58 + fill-rule="evenodd" 59 + 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" 60 + clip-rule="evenodd" 61 + /> 62 + </svg> 63 + {/each} 64 + {/if} 65 + </div> 66 + </div>
+185
src/lib/Components/Items/ReviewCard.svelte
··· 1 + <script lang="ts"> 2 + import type { MainRecord } from '$lib/db'; 3 + import { toast } from 'svelte-sonner'; 4 + 5 + import Rating from './Rating.svelte'; 6 + import RelativeTime from '$lib/Components/Utils/relative-time/RelativeTime.svelte'; 7 + import OptionButton from '$lib/Components/Modals/OptionMenu.svelte'; 8 + 9 + import { nameToId } from '$lib/utils'; 10 + 11 + let { data, showMovieDetails = true }: { data: MainRecord; showMovieDetails?: boolean } = 12 + $props(); 13 + 14 + let isLiked = $state(false); 15 + </script> 16 + 17 + <div class="relative w-full max-w-2xl p-4 backdrop-blur-sm"> 18 + <div class="flex max-w-full items-center gap-4 overflow-hidden"> 19 + {#if showMovieDetails} 20 + <a 21 + href={data.record.item.ref === 'tmdb:m' 22 + ? `/movie/${data.record.item.value}-${nameToId(data.record.metadata?.title ?? '')}` 23 + : `/tv/${data.record.item.value}-${nameToId(data.record.metadata?.title ?? '')}`} 24 + class="relative z-20 mr-4 aspect-[2/3] h-32 w-auto shrink-0 overflow-hidden rounded-md border border-base-800 bg-base-900/50 transition-opacity duration-75 hover:opacity-80 group-hover:opacity-75" 25 + > 26 + {#if data.record.metadata?.poster_path} 27 + <img 28 + src="https://image.tmdb.org/t/p/w154{data.record.metadata.poster_path}" 29 + alt="movie poster for {data.record.metadata.title}" 30 + class="poster size-full object-cover object-center lg:size-full" 31 + style:--name={`poster-${data.record.item.ref}-${data.record.item.value}`} 32 + loading="lazy" 33 + /> 34 + {/if} 35 + <span class="sr-only">View {data.record.metadata?.title}</span> 36 + </a> 37 + {/if} 38 + 39 + <div class="flex flex-col gap-3"> 40 + <a 41 + href={`/user/${data.author.handle}`} 42 + class="z-20 flex flex-row items-center gap-4 overflow-hidden font-medium" 43 + > 44 + <div class="flex items-center gap-2"> 45 + {#if data.author.avatar} 46 + <img 47 + src={data.author.avatar.replace('avatar', 'avatar_thumbnail')} 48 + alt="user avatar" 49 + loading="lazy" 50 + class="size-6 rounded-full" 51 + /> 52 + {/if} 53 + <div class="truncate text-sm font-medium"> 54 + {data.author.displayName || data.author.handle} 55 + </div> 56 + </div> 57 + <div class="shrink-0 pr-2 text-sm text-base-400 font-normal"> 58 + <RelativeTime date={new Date(data.updatedAt)} locale="en-US" /> 59 + </div> 60 + </a> 61 + 62 + <div class="flex flex-col gap-3"> 63 + {#if showMovieDetails} 64 + <a 65 + href={data.record.item.ref === 'tmdb:m' 66 + ? `/movie/${data.record.item.value}-${nameToId(data.record.metadata?.title ?? '')}` 67 + : `/tv/${data.record.item.value}-${nameToId(data.record.metadata?.title ?? '')}`} 68 + class="title z-20 text-2xl font-bold hover:text-accent-200" 69 + style:--name={`title-${data.record.item.value}`} 70 + > 71 + {data.record.metadata?.title} 72 + </a> 73 + {/if} 74 + {#if data.record.rating?.value} 75 + <a href={`/review/${encodeURIComponent(data.uri)}`}> 76 + <Rating rating={data.record.rating?.value / 2} size={'size-6'} /> 77 + <span class="sr-only">View review</span> 78 + </a> 79 + {/if} 80 + 81 + <!-- <button 82 + onclick={() => { 83 + crosspostModal.show( 84 + data.uri, 85 + data.record.note?.value ?? '', 86 + (data.record.rating?.value ?? 0) / 2, 87 + data.record.metadata?.title ?? '' 88 + ); 89 + }} 90 + > 91 + crosspost 92 + </button> --> 93 + </div> 94 + </div> 95 + </div> 96 + 97 + {#if data.record.note?.value} 98 + <div class="mt-4 text-sm text-base-300"> 99 + {@html data.record.note?.value?.replace('\n', '<br /><br />')} 100 + </div> 101 + {/if} 102 + 103 + <div class="mt-6 flex justify-between gap-2 text-base-500 dark:text-base-400"> 104 + <!-- <button class="group inline-flex items-center gap-2 text-sm"> 105 + <svg 106 + xmlns="http://www.w3.org/2000/svg" 107 + fill="none" 108 + viewBox="0 0 24 24" 109 + stroke-width="1.5" 110 + stroke="currentColor" 111 + class="-m-1.5 size-7 rounded-full p-1.5 transition-all duration-100 group-hover:bg-amber-500/10 group-hover:text-amber-700 dark:group-hover:text-amber-400" 112 + > 113 + <path 114 + stroke-linecap="round" 115 + stroke-linejoin="round" 116 + d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 0 1-.923 1.785A5.969 5.969 0 0 0 6 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337Z" 117 + /> 118 + </svg> 119 + {#if data.post.replyCount} 120 + {numberToHumanReadable(data.post.replyCount)} 121 + {/if} 122 + </button> --> 123 + 124 + <button 125 + class="group inline-flex items-center gap-2 text-sm" 126 + onclick={async () => { 127 + if (isLiked) return; 128 + 129 + isLiked = true; 130 + 131 + try { 132 + const response = await fetch('/api/like', { 133 + method: 'POST', 134 + body: JSON.stringify({ cid: data.cid, uri: data.uri }) 135 + }); 136 + if (response.ok) { 137 + toast.success('Liked'); 138 + } else { 139 + toast.error('must be logged in to like'); 140 + isLiked = false; 141 + } 142 + } catch (e) { 143 + console.error(e); 144 + toast.error('must be logged in to like'); 145 + isLiked = false; 146 + } 147 + }} 148 + > 149 + {#if isLiked} 150 + <svg 151 + xmlns="http://www.w3.org/2000/svg" 152 + viewBox="0 0 24 24" 153 + fill="currentColor" 154 + class="-m-1.5 size-7 rounded-full p-1.5 transition-all duration-100 group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400" 155 + > 156 + <path 157 + d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" 158 + /> 159 + </svg> 160 + {:else} 161 + <svg 162 + xmlns="http://www.w3.org/2000/svg" 163 + fill="none" 164 + viewBox="0 0 24 24" 165 + stroke-width="1.5" 166 + stroke="currentColor" 167 + class="-m-1.5 size-7 rounded-full p-1.5 transition-all duration-100 group-hover:bg-accent-500/10 group-hover:text-accent-700 dark:group-hover:text-accent-400" 168 + > 169 + <path 170 + stroke-linecap="round" 171 + stroke-linejoin="round" 172 + d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" 173 + /> 174 + </svg> 175 + {/if} 176 + {(data.record.likes ?? 0) + (isLiked ? 1 : 0)} 177 + </button> 178 + 179 + <OptionButton data={data} /> 180 + </div> 181 + 182 + <!-- <a href={`/review/${encodeURIComponent(data.uri)}`}> 183 + <span class="absolute inset-0 z-10 hover:bg-accent-500/10"></span> 184 + </a> --> 185 + </div>
+17
src/lib/Components/Items/ReviewList.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '$lib/utils'; 3 + import type { MainRecord } from '$lib/db'; 4 + import ReviewCard from './ReviewCard.svelte'; 5 + 6 + let { 7 + reviews, 8 + class: className, 9 + showMovieDetails 10 + }: { reviews: MainRecord[]; class?: string; showMovieDetails?: boolean } = $props(); 11 + </script> 12 + 13 + <div class={cn('flex w-full flex-col divide-y divide-base-900', className)}> 14 + {#each reviews as review} 15 + <ReviewCard data={review} {showMovieDetails} /> 16 + {/each} 17 + </div>
+22
src/lib/Components/Layout/BaseHeadTags.svelte
··· 1 + <svelte:head> 2 + <meta name="description" content="review movies and shows with your friends from bluesky." /> 3 + 4 + <meta property="og:url" content="https://skywatched.app" /> 5 + <meta property="og:type" content="website" /> 6 + <meta property="og:title" content="skywatched" /> 7 + <meta 8 + property="og:description" 9 + content="review movies and shows with your friends from bluesky." 10 + /> 11 + <meta property="og:image" content="/og.jpg" /> 12 + 13 + <meta name="twitter:card" content="summary_large_image" /> 14 + <meta property="twitter:domain" content="skywatched.app" /> 15 + <meta property="twitter:url" content="https://skywatched.app" /> 16 + <meta name="twitter:title" content="skywatched" /> 17 + <meta 18 + name="twitter:description" 19 + content="review movies and shows with your friends from bluesky." 20 + /> 21 + <meta name="twitter:image" content="/og.jpg" /> 22 + </svelte:head>
+9
src/lib/Components/Layout/Container.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '$lib/utils'; 3 + 4 + const { children, class: className }: { children: () => any; class?: string } = $props(); 5 + </script> 6 + 7 + <div class={cn('mx-auto max-w-2xl sm:px-6 lg:max-w-4xl lg:px-8', className)}> 8 + {@render children?.()} 9 + </div>
+30
src/lib/Components/Layout/Footer.svelte
··· 1 + <footer class="relative z-10 border-t border-base-800 bg-black"> 2 + <div 3 + class="mx-auto max-w-2xl py-12 sm:px-6 md:flex md:items-center md:justify-between lg:max-w-4xl lg:px-8 xl:max-w-6xl" 4 + > 5 + <div class="flex justify-center gap-x-6 md:order-2"> 6 + <a 7 + href="https://github.com/flo-bit/skywatched" 8 + class="text-base-400 hover:text-base-300" 9 + target="_blank" 10 + > 11 + <span class="sr-only">see source code</span> 12 + <svg class="size-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> 13 + <path 14 + fill-rule="evenodd" 15 + d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" 16 + clip-rule="evenodd" 17 + /> 18 + </svg> 19 + </a> 20 + </div> 21 + <div class="mt-8 flex flex-col items-center md:order-1 md:items-start"> 22 + <a href="https://www.themoviedb.org/" target="_blank"> 23 + <img src="/tmdb_logo.svg" alt="TMDB logo" class="h-3" height="12" width="165" /> 24 + </a> 25 + <p class="mt-2 text-center text-xs text-base-400"> 26 + This website uses the TMDB API but is not endorsed or certified by TMDB. 27 + </p> 28 + </div> 29 + </div> 30 + </footer>
+176
src/lib/Components/Layout/Sidebar.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/stores'; 3 + import { cn } from '$lib/utils'; 4 + import User from '$lib/Components/User/User.svelte'; 5 + import { rateMovieModal, showSidebar } from '$lib/state/modals.svelte'; 6 + import { fade } from 'svelte/transition'; 7 + import { onNavigate } from '$app/navigation'; 8 + 9 + const menu = [ 10 + { 11 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 12 + <path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> 13 + </svg>`, 14 + label: 'Home', 15 + href: '/' 16 + }, 17 + { 18 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 19 + <path stroke-linecap="round" stroke-linejoin="round" d="M12.75 3.03v.568c0 .334.148.65.405.864l1.068.89c.442.369.535 1.01.216 1.49l-.51.766a2.25 2.25 0 0 1-1.161.886l-.143.048a1.107 1.107 0 0 0-.57 1.664c.369.555.169 1.307-.427 1.605L9 13.125l.423 1.059a.956.956 0 0 1-1.652.928l-.679-.906a1.125 1.125 0 0 0-1.906.172L4.5 15.75l-.612.153M12.75 3.031a9 9 0 0 0-8.862 12.872M12.75 3.031a9 9 0 0 1 6.69 14.036m0 0-.177-.529A2.25 2.25 0 0 0 17.128 15H16.5l-.324-.324a1.453 1.453 0 0 0-2.328.377l-.036.073a1.586 1.586 0 0 1-.982.816l-.99.282c-.55.157-.894.702-.8 1.267l.073.438c.08.474.49.821.97.821.846 0 1.598.542 1.865 1.345l.215.643m5.276-3.67a9.012 9.012 0 0 1-5.276 3.67m0 0a9 9 0 0 1-10.275-4.835M15.75 9c0 .896-.393 1.7-1.016 2.25" /> 20 + </svg>`, 21 + label: 'Feed', 22 + href: '/feed' 23 + }, 24 + { 25 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 26 + <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /> 27 + </svg>`, 28 + label: 'Search', 29 + href: '/search' 30 + } 31 + ]; 32 + 33 + const userMenu = [ 34 + { 35 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 36 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 37 + </svg>`, 38 + label: 'New review', 39 + href: '/review/new', 40 + onclick: () => { 41 + showSidebar.value = false; 42 + rateMovieModal.showEmpty(); 43 + } 44 + }, 45 + { 46 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> 47 + <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /> 48 + <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /> 49 + </svg>`, 50 + label: 'Settings', 51 + href: '/settings' 52 + } 53 + ]; 54 + 55 + let { user } = $props(); 56 + 57 + onNavigate(() => { 58 + showSidebar.value = false; 59 + }); 60 + </script> 61 + 62 + {#key $page.url.pathname} 63 + <div 64 + class={cn( 65 + 'sidebar fixed bottom-2 left-0 top-2 z-50 w-[4.5rem] py-2 transition-transform duration-300', 66 + showSidebar.value ? 'translate-x-0' : '-translate-x-64 md:translate-x-0' 67 + )} 68 + > 69 + <div class="flex h-full flex-col items-center justify-end space-y-1 pb-2 md:justify-between"> 70 + <ul class="flex flex-col items-center space-y-2"> 71 + {#each menu as item} 72 + <li class="group relative size-12"> 73 + <a 74 + href={item.href} 75 + class={cn( 76 + 'group flex items-center gap-x-3 rounded-md p-3 text-sm/6 font-semibold', 77 + $page.url.pathname === item.href 78 + ? 'bg-accent-950/10 text-accent-400' 79 + : 'text-base-200 transition-colors duration-100 hover:bg-accent-950/20 hover:text-accent-400' 80 + )} 81 + > 82 + {@html item.icon} 83 + <span 84 + class="pointer-events-none absolute left-14 rounded-lg transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100 md:bg-accent-950/20 md:px-3 md:py-2 md:opacity-0" 85 + >{item.label}</span 86 + > 87 + 88 + {#if showSidebar.value} 89 + <span class="absolute inset-0 block h-full w-screen md:hidden"></span> 90 + {/if} 91 + </a> 92 + </li> 93 + {/each} 94 + </ul> 95 + <ul class="flex flex-col items-center space-y-2"> 96 + {#if user} 97 + {#each userMenu as item} 98 + <li class="group relative"> 99 + <a 100 + href={item.href} 101 + class={'group flex items-center gap-x-3 rounded-md p-3 text-sm/6 font-semibold text-base-200 transition-colors duration-100 hover:bg-accent-950/20 hover:text-accent-400'} 102 + onclick={(event) => { 103 + if (item.onclick) { 104 + event.preventDefault(); 105 + item.onclick(); 106 + } 107 + }} 108 + > 109 + {@html item.icon} 110 + <span 111 + class="pointer-events-none absolute left-14 whitespace-nowrap rounded-lg backdrop-blur-md transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100 md:bg-accent-950/20 md:px-3 md:py-2 md:opacity-0" 112 + >{item.label}</span 113 + > 114 + 115 + {#if showSidebar.value} 116 + <span class="absolute inset-0 block h-full w-screen md:hidden"></span> 117 + {/if} 118 + </a> 119 + </li> 120 + {/each} 121 + {/if} 122 + 123 + <User {user} /> 124 + </ul> 125 + </div> 126 + </div> 127 + {/key} 128 + 129 + {#if showSidebar.value} 130 + <button 131 + transition:fade 132 + class="fixed inset-0 z-40 bg-base-950/90 backdrop-blur-sm md:hidden" 133 + onclick={() => showSidebar.toggle()} 134 + > 135 + <span class="sr-only">Close Menu</span> 136 + </button> 137 + 138 + <button 139 + transition:fade 140 + onclick={() => showSidebar.toggle()} 141 + class="fixed bottom-6 right-4 z-50 md:hidden" 142 + > 143 + <span class="sr-only">close Menu</span> 144 + 145 + <svg 146 + xmlns="http://www.w3.org/2000/svg" 147 + fill="none" 148 + viewBox="0 0 24 24" 149 + stroke-width="1.5" 150 + stroke="currentColor" 151 + class="size-6" 152 + > 153 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 154 + </svg> 155 + </button> 156 + {:else} 157 + <button 158 + class="fixed bottom-2 left-2 z-50 rounded-lg border border-base-800 bg-base-900/75 p-2 backdrop-blur-sm md:hidden" 159 + onclick={() => showSidebar.toggle()} 160 + > 161 + <span class="sr-only">Open Menu</span> 162 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6"> 163 + <path 164 + fill-rule="evenodd" 165 + d="M3 9a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 9Zm0 6.75a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75a.75.75 0 0 1-.75-.75Z" 166 + clip-rule="evenodd" 167 + /> 168 + </svg> 169 + </button> 170 + {/if} 171 + 172 + <style> 173 + .sidebar { 174 + view-transition-name: sidebar; 175 + } 176 + </style>
+125
src/lib/Components/Modals/CrosspostModal.svelte
··· 1 + <script lang="ts"> 2 + import { crosspostModal } from '$lib/state/modals.svelte'; 3 + import { user } from '$lib/state/user.svelte'; 4 + import { toast } from 'svelte-sonner'; 5 + 6 + let ratingText = $derived.by(() => { 7 + const ratingText = `I rated "${crosspostModal.title}" with ${crosspostModal.rating} star${crosspostModal.rating === 1 ? '' : 's'} on skywatched.app`; 8 + 9 + let text = crosspostModal.review 10 + ? `My review for "${crosspostModal.title}" on skywatched.app:<br /> <br />${crosspostModal.review}` 11 + : ratingText; 12 + 13 + if (text.length > 299) { 14 + text = text.slice(0, 295) + '...'; 15 + } 16 + 17 + return text; 18 + }); 19 + </script> 20 + 21 + {#if crosspostModal.showModal} 22 + <div class="relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true"> 23 + <div 24 + class="fixed inset-0 bg-base-950/90 backdrop-blur-sm transition-opacity" 25 + onclick={() => (crosspostModal.showModal = false)} 26 + aria-hidden="true" 27 + ></div> 28 + <div class="pointer-events-none fixed inset-0 z-50 h-[100dvh] w-screen overflow-y-auto"> 29 + <div class="flex h-[100dvh] items-end justify-center p-4 text-center sm:items-center sm:p-0"> 30 + <div 31 + class="pointer-events-auto relative w-full transform overflow-hidden rounded-lg border border-base-800 bg-base-900 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:max-w-md sm:p-6" 32 + > 33 + <button 34 + class="absolute right-2 top-2 rounded-full bg-base-800/50 p-1 hover:bg-base-800/80" 35 + onclick={() => (crosspostModal.showModal = false)} 36 + > 37 + <svg 38 + xmlns="http://www.w3.org/2000/svg" 39 + fill="none" 40 + viewBox="0 0 24 24" 41 + stroke-width="1.5" 42 + stroke="currentColor" 43 + class="size-4" 44 + > 45 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 46 + </svg> 47 + 48 + <span class="sr-only">close</span> 49 + </button> 50 + 51 + <div> 52 + <h3 class="text-md mb-4 font-semibold text-base-50" id="modal-title"> 53 + Crosspost to Bluesky 54 + </h3> 55 + 56 + <p class="text-sm text-base-300">Do you want to crosspost this review to Bluesky?</p> 57 + 58 + <div class="mt-4 rounded-xl border border-base-800 bg-base-950 p-4"> 59 + <div class="flex items-center gap-2"> 60 + {#if user.avatar} 61 + <img src={user.avatar} alt="user avatar" class="size-5 rounded-full" /> 62 + {/if} 63 + <div class="truncate text-sm font-medium"> 64 + {user.displayName || user.handle} 65 + </div> 66 + </div> 67 + 68 + <p class="mt-2 text-sm text-base-300"> 69 + {@html ratingText} 70 + </p> 71 + 72 + <div 73 + class="relative mt-4 aspect-2 h-auto w-full overflow-hidden rounded-md border border-base-800 bg-base-800" 74 + > 75 + <div 76 + class="absolute inset-0 flex h-full w-full items-center justify-center bg-base-950/90" 77 + > 78 + <p>Loading image...</p> 79 + </div> 80 + <img 81 + src="/review/{encodeURIComponent(crosspostModal.uri ?? '')}/og.png" 82 + alt="crosspost" 83 + class="absolute inset-0 h-full w-full object-cover" 84 + /> 85 + </div> 86 + </div> 87 + </div> 88 + <div class="mt-5 sm:mt-6"> 89 + <button 90 + onclick={async () => { 91 + crosspostModal.hide(); 92 + }} 93 + type="button" 94 + class="inline-flex w-full justify-center rounded-md border border-base-700 bg-white/5 px-3 py-2 text-sm font-semibold text-base-300 shadow-sm hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600" 95 + >No thanks</button 96 + > 97 + 98 + <button 99 + onclick={async () => { 100 + crosspostModal.showModal = false; 101 + 102 + const response = await fetch(`/api/crosspost`, { 103 + method: 'POST', 104 + body: JSON.stringify({ 105 + uri: crosspostModal.uri 106 + }) 107 + }); 108 + 109 + if (!response.ok) { 110 + toast.error('Failed to crosspost'); 111 + return; 112 + } 113 + 114 + toast.success('Crossposted to Bluesky'); 115 + }} 116 + type="button" 117 + class="mt-2 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" 118 + >Post to Bluesky</button 119 + > 120 + </div> 121 + </div> 122 + </div> 123 + </div> 124 + </div> 125 + {/if}
+72
src/lib/Components/Modals/LoginModal.svelte
··· 1 + <script lang="ts"> 2 + import { showLoginModal } from '$lib/state/modals.svelte'; 3 + </script> 4 + 5 + {#if showLoginModal.value} 6 + <div 7 + class="fixed inset-0 z-[100] w-screen overflow-y-auto" 8 + aria-labelledby="modal-title" 9 + role="dialog" 10 + aria-modal="true" 11 + > 12 + <div 13 + class="fixed inset-0 bg-base-950/75 backdrop-blur-sm transition-opacity" 14 + onclick={() => showLoginModal.toggle()} 15 + aria-hidden="true" 16 + ></div> 17 + 18 + <div class="pointer-events-none fixed inset-0 z-10 w-screen overflow-y-auto"> 19 + <div 20 + class="flex min-h-[100dvh] items-end justify-center p-4 text-center sm:items-center sm:p-0" 21 + > 22 + <div 23 + class="pointer-events-auto relative w-full transform overflow-hidden rounded-lg border border-base-700 bg-base-800 px-4 pb-4 pt-2 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6" 24 + > 25 + <div> 26 + <div class="text-center"> 27 + <h3 class="text-base font-semibold text-base-900 dark:text-base-100" id="modal-title"> 28 + Login with Bluesky 29 + </h3> 30 + </div> 31 + </div> 32 + <form action="/?/login" method="POST" class="mt-2 flex w-full flex-col gap-2"> 33 + <div class="w-full"> 34 + <label 35 + for="handle" 36 + class="block text-sm/6 font-medium text-base-900 dark:text-base-100">Handle</label 37 + > 38 + <div class="mt-2"> 39 + <input 40 + type="text" 41 + name="handle" 42 + id="handle" 43 + 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 dark:bg-base-900 dark:text-base-100 dark:ring-base-700 dark:placeholder:text-base-600 sm:text-sm/6" 44 + placeholder="yourname.bsky.social" 45 + /> 46 + </div> 47 + </div> 48 + <div class="mt-5 sm:mt-6"> 49 + <button 50 + type="submit" 51 + class="inline-flex w-full justify-center rounded-md bg-accent-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-accent-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600" 52 + >Login</button 53 + > 54 + </div> 55 + 56 + <div class="mt-4 border-t border-base-700 pt-4 text-sm leading-7 text-base-300"> 57 + Don't have an account? 58 + <br /> 59 + <a 60 + href="https://bsky.app" 61 + target="_blank" 62 + class="font-medium text-accent-400 hover:text-accent-500" 63 + > 64 + Create one on bluesky 65 + </a> and come back here. 66 + </div> 67 + </form> 68 + </div> 69 + </div> 70 + </div> 71 + </div> 72 + {/if}
+197
src/lib/Components/Modals/OptionMenu.svelte
··· 1 + <script lang="ts"> 2 + import { type MainRecord } from '$lib/db'; 3 + import { rateMovieModal } from '$lib/state/modals.svelte'; 4 + import { user } from '$lib/state/user.svelte'; 5 + import { createDropdownMenu, melt } from '@melt-ui/svelte'; 6 + import { toast } from 'svelte-sonner'; 7 + import { fly } from 'svelte/transition'; 8 + 9 + const { 10 + elements: { trigger, menu }, 11 + states: { open } 12 + } = createDropdownMenu({ 13 + forceVisible: true, 14 + loop: true 15 + }); 16 + 17 + export let data: MainRecord; 18 + </script> 19 + 20 + <button type="button" class="" use:melt={$trigger}> 21 + <svg 22 + xmlns="http://www.w3.org/2000/svg" 23 + fill="none" 24 + viewBox="0 0 24 24" 25 + stroke-width="1.5" 26 + stroke="currentColor" 27 + class="size-4" 28 + > 29 + <path 30 + stroke-linecap="round" 31 + stroke-linejoin="round" 32 + d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" 33 + /> 34 + </svg> 35 + 36 + <span class="sr-only">show options</span> 37 + </button> 38 + 39 + {#if $open} 40 + <div 41 + class="bg-base-900 border-base-800 shadow-xl shadow-base-950 divide-base-800 z-20 flex flex-col items-start divide-y overflow-hidden rounded-xl border" 42 + use:melt={$menu} 43 + transition:fly={{ duration: 150, y: -10 }} 44 + > 45 + {#if data.author.handle === user?.handle} 46 + <button 47 + class="item" 48 + on:click={async () => { 49 + const response = await fetch(`/api/review?uri=${encodeURIComponent(data.uri)}`, { 50 + method: 'DELETE' 51 + }); 52 + console.log(await response.json()); 53 + 54 + $open = false; 55 + 56 + await new Promise((resolve) => setTimeout(resolve, 1000)); 57 + 58 + if (window.location.pathname.includes('/review/')) { 59 + window.location.href = '/'; 60 + } else { 61 + window.location.reload(); 62 + } 63 + }} 64 + ><svg 65 + xmlns="http://www.w3.org/2000/svg" 66 + fill="none" 67 + viewBox="0 0 24 24" 68 + stroke-width="1" 69 + stroke="currentColor" 70 + class="size-4" 71 + > 72 + <path 73 + stroke-linecap="round" 74 + stroke-linejoin="round" 75 + d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" 76 + /> 77 + </svg> 78 + 79 + delete review</button 80 + > 81 + <button 82 + class="item" 83 + on:click={() => { 84 + $open = false; 85 + 86 + rateMovieModal.show({ 87 + id: parseInt(data.record.item.value), 88 + ref: data.record.item.ref as Ref, 89 + media_type: data.record.item.ref === 'tmdb:s' ? 'tv' : 'movie', 90 + title: data.record.metadata?.title ?? '', 91 + poster_path: data.record.metadata?.poster_path ?? '', 92 + currentRating: (data.record.rating?.value ?? 0) / 2, 93 + currentReview: data.record.note?.value, 94 + editUri: data.uri 95 + }); 96 + }} 97 + ><svg 98 + xmlns="http://www.w3.org/2000/svg" 99 + fill="none" 100 + viewBox="0 0 24 24" 101 + stroke-width="1" 102 + stroke="currentColor" 103 + class="size-4" 104 + > 105 + <path 106 + stroke-linecap="round" 107 + stroke-linejoin="round" 108 + d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" 109 + /> 110 + </svg> 111 + 112 + edit review</button 113 + > 114 + {:else} 115 + <!-- <button 116 + class="item" 117 + on:click={() => { 118 + alert('Check for Updates...'); 119 + }} 120 + > 121 + <svg 122 + xmlns="http://www.w3.org/2000/svg" 123 + fill="none" 124 + viewBox="0 0 24 24" 125 + stroke-width="1" 126 + stroke="currentColor" 127 + class="size-4" 128 + > 129 + <path 130 + stroke-linecap="round" 131 + stroke-linejoin="round" 132 + d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" 133 + /> 134 + </svg> 135 + 136 + report review</button 137 + > --> 138 + {/if} 139 + 140 + {#if data.record.crosspost?.uri} 141 + <button class="item" on:click={() => {}}> 142 + <svg 143 + xmlns="http://www.w3.org/2000/svg" 144 + fill="none" 145 + viewBox="0 0 24 24" 146 + stroke-width="1" 147 + stroke="currentColor" 148 + class="size-4" 149 + > 150 + <path 151 + stroke-linecap="round" 152 + stroke-linejoin="round" 153 + d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3" 154 + /> 155 + </svg> 156 + go to crosspost 157 + </button> 158 + {/if} 159 + 160 + <button 161 + class="item" 162 + on:click={() => { 163 + $open = false; 164 + 165 + // copy link to clipboard 166 + navigator.clipboard.writeText( 167 + 'https://skywatched.app/review/' + encodeURIComponent(data.uri) 168 + ); 169 + 170 + toast.success('Link copied to clipboard'); 171 + }} 172 + > 173 + <svg 174 + xmlns="http://www.w3.org/2000/svg" 175 + fill="none" 176 + viewBox="0 0 24 24" 177 + stroke-width="1" 178 + stroke="currentColor" 179 + class="size-4" 180 + > 181 + <path 182 + stroke-linecap="round" 183 + stroke-linejoin="round" 184 + d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5A3.375 3.375 0 0 0 6.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0 0 15 2.25h-1.5a2.251 2.251 0 0 0-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 0 0-9-9Z" 185 + /> 186 + </svg> 187 + 188 + copy link to post 189 + </button> 190 + </div> 191 + {/if} 192 + 193 + <style> 194 + .item { 195 + @apply text-base-300 hover:bg-accent-950/30 hover:text-accent-400 inline-flex w-full items-center gap-2 px-3 py-2 text-sm; 196 + } 197 + </style>
+190
src/lib/Components/Modals/RateMovieModal.svelte
··· 1 + <script lang="ts"> 2 + import { crosspostModal, rateMovieModal } from '$lib/state/modals.svelte'; 3 + import { settings, watchedItems } from '$lib/state/user.svelte'; 4 + import { toast } from 'svelte-sonner'; 5 + import Rating from '$lib/Components/Items/Rating.svelte'; 6 + import SearchCombobox from '$lib/Components/UI/SearchCombobox.svelte'; 7 + 8 + let rating = $state(rateMovieModal.selectedItem?.currentRating ?? 0); 9 + let review = $state(rateMovieModal.selectedItem?.currentReview ?? ''); 10 + 11 + $effect(() => { 12 + rating = rateMovieModal.selectedItem?.currentRating ?? 0; 13 + review = rateMovieModal.selectedItem?.currentReview ?? ''; 14 + }); 15 + 16 + let sending = $state(false); 17 + 18 + async function saveNew() { 19 + const response = await fetch(`/api/rate`, { 20 + method: 'POST', 21 + body: JSON.stringify({ 22 + rating, 23 + review, 24 + ref: rateMovieModal.selectedItem?.ref 25 + }) 26 + }); 27 + 28 + if (!response.ok) { 29 + toast.error('Failed to save rating'); 30 + 31 + return; 32 + } 33 + rateMovieModal.showModal = false; 34 + 35 + const data = await response.json(); 36 + 37 + if (rateMovieModal.selectedItem) { 38 + watchedItems.addRated({ 39 + ...rateMovieModal.selectedItem, 40 + rating 41 + }); 42 + } 43 + 44 + toast.success('Rating saved'); 45 + 46 + if (settings.crosspostEnabled && review.length > 0) { 47 + await new Promise((resolve) => setTimeout(resolve, 1000)); 48 + 49 + crosspostModal.show(data.uri, review, rating, rateMovieModal?.selectedItem?.title ?? ''); 50 + } 51 + } 52 + 53 + async function saveEdit() { 54 + const response = await fetch(`/api/review`, { 55 + method: 'PUT', 56 + body: JSON.stringify({ 57 + rating, 58 + review, 59 + ...rateMovieModal?.selectedItem, 60 + uri: rateMovieModal?.selectedItem?.editUri 61 + }) 62 + }); 63 + 64 + if (!response.ok) { 65 + toast.error('Failed to save rating'); 66 + 67 + return; 68 + } 69 + 70 + toast.success('Rating saved'); 71 + 72 + rateMovieModal.showModal = false; 73 + 74 + await new Promise((resolve) => setTimeout(resolve, 1000)); 75 + 76 + window.location.reload(); 77 + } 78 + </script> 79 + 80 + {#if rateMovieModal.showModal} 81 + <div class="relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true"> 82 + <div 83 + class="bg-base-950/90 fixed inset-0 backdrop-blur-sm transition-opacity" 84 + onclick={() => (rateMovieModal.showModal = false)} 85 + aria-hidden="true" 86 + ></div> 87 + <div class="pointer-events-none fixed inset-0 z-50 h-[100dvh] w-screen overflow-y-auto"> 88 + <div class="flex h-[100dvh] items-end justify-center p-4 text-center sm:items-center sm:p-0"> 89 + <div 90 + class="border-base-800 bg-base-900 pointer-events-auto relative w-full transform overflow-hidden rounded-lg border px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:max-w-sm sm:p-6" 91 + > 92 + <button 93 + class="bg-base-800/50 hover:bg-base-800/80 absolute right-2 top-2 rounded-full p-1" 94 + onclick={() => (rateMovieModal.showModal = false)} 95 + > 96 + <svg 97 + xmlns="http://www.w3.org/2000/svg" 98 + fill="none" 99 + viewBox="0 0 24 24" 100 + stroke-width="1.5" 101 + stroke="currentColor" 102 + class="size-4" 103 + > 104 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 105 + </svg> 106 + 107 + <span class="sr-only">close</span> 108 + </button> 109 + 110 + <div> 111 + <h3 class="text-md text-base-50 mb-4 font-semibold" id="modal-title"> 112 + {#if rateMovieModal.selectedItem?.editUri} 113 + Edit review 114 + {:else} 115 + Rate and review 116 + {/if} 117 + </h3> 118 + 119 + {#if rateMovieModal.selectedItem?.title} 120 + <div class="relative flex items-center gap-4"> 121 + <div 122 + class="border-base-800 bg-base-900/50 relative z-20 aspect-[2/3] h-32 w-auto shrink-0 overflow-hidden rounded-md border" 123 + > 124 + {#if rateMovieModal.selectedItem?.poster_path} 125 + <img 126 + src="https://image.tmdb.org/t/p/w154{rateMovieModal.selectedItem.poster_path}" 127 + alt="movie poster for {rateMovieModal.selectedItem.title}" 128 + class="size-full object-cover object-center lg:size-full" 129 + /> 130 + {/if} 131 + </div> 132 + <h3 133 + class="text-base-50 mb-4 flex flex-col gap-2 text-xl font-semibold" 134 + id="modal-title" 135 + > 136 + {rateMovieModal.selectedItem.title} 137 + 138 + <Rating bind:rating canChange size="size-7" /> 139 + </h3> 140 + <!-- <div class="absolute right-2 top-0"> 141 + <button 142 + onclick={() => (rateMovieModal.showModal = false)} 143 + class="rounded-full bg-base-800/50 p-1 px-2 text-xs text-base-50 hover:bg-base-800/70 hover:text-base-100" 144 + > 145 + change 146 + </button> 147 + </div> --> 148 + </div> 149 + {:else} 150 + <SearchCombobox /> 151 + {/if} 152 + 153 + <div class="mt-4"> 154 + <label for="comment" class="text-base-50 block text-xs font-medium">review</label> 155 + <div class="mt-2"> 156 + <textarea 157 + rows="4" 158 + name="comment" 159 + id="comment" 160 + bind:value={review} 161 + placeholder="write a review" 162 + class="outline-nonse border-base-800 bg-base-950 text-base-50 placeholder:text-base-400 focus:outline-accent-400 block w-full rounded-lg border px-3 py-1.5 text-base -outline-offset-1 focus:outline focus:outline-2 focus:-outline-offset-2 sm:text-sm/6" 163 + ></textarea> 164 + </div> 165 + </div> 166 + </div> 167 + <div class="mt-5 sm:mt-6"> 168 + <button 169 + onclick={async () => { 170 + if (rateMovieModal.selectedItem?.editUri) { 171 + saveEdit(); 172 + } else { 173 + saveNew(); 174 + } 175 + }} 176 + type="button" 177 + disabled={sending || !rateMovieModal.selectedItem?.title} 178 + class="border-accent-900 bg-accent-950/80 text-accent-300 hover:bg-accent-950 focus-visible:outline-accent-600 inline-flex w-full justify-center rounded-md border px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50" 179 + >{sending 180 + ? 'Sending...' 181 + : rateMovieModal.selectedItem?.editUri 182 + ? 'Update' 183 + : 'Review'}</button 184 + > 185 + </div> 186 + </div> 187 + </div> 188 + </div> 189 + </div> 190 + {/if}
+9
src/lib/Components/UI/Input.svelte
··· 1 + <script lang="ts"> 2 + let { value = $bindable(), ...rest } = $props(); 3 + </script> 4 + 5 + <input 6 + bind:value 7 + class="block w-full rounded-full 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 dark:bg-base-950 dark:text-base-100 dark:ring-base-700 dark:placeholder:text-base-600 sm:text-sm/6" 8 + {...rest} 9 + />
+18
src/lib/Components/UI/Logo.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '$lib/utils'; 3 + 4 + let { class: className }: { class: string } = $props(); 5 + </script> 6 + 7 + <svg 8 + xmlns="http://www.w3.org/2000/svg" 9 + viewBox="0 0 24 24" 10 + fill="currentColor" 11 + class={cn('size-6 fill-accent-500', className)} 12 + > 13 + <path 14 + fill-rule="evenodd" 15 + d="M9.528 1.718a.75.75 0 0 1 .162.819A8.97 8.97 0 0 0 9 6a9 9 0 0 0 9 9 8.97 8.97 0 0 0 3.463-.69.75.75 0 0 1 .981.98 10.503 10.503 0 0 1-9.694 6.46c-5.799 0-10.5-4.7-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 0 1 .818.162Z" 16 + clip-rule="evenodd" 17 + /> 18 + </svg>
+145
src/lib/Components/UI/SearchCombobox.svelte
··· 1 + <script lang="ts"> 2 + import { rateMovieModal } from '$lib/state/modals.svelte'; 3 + import { watchedItems } from '$lib/state/user.svelte'; 4 + import type { Item } from '$lib/types'; 5 + import { createCombobox, melt } from '@melt-ui/svelte'; 6 + import { fly } from 'svelte/transition'; 7 + 8 + const { 9 + elements: { menu, input, option, label }, 10 + states: { open, inputValue, touchedInput }, 11 + helpers: { isSelected } 12 + } = createCombobox({ 13 + forceVisible: true 14 + }); 15 + 16 + let debounceTimer: ReturnType<typeof setTimeout>; 17 + 18 + const debounce = (callback: () => void) => { 19 + clearTimeout(debounceTimer); 20 + debounceTimer = setTimeout(callback, 500); 21 + }; 22 + 23 + let results: Item[] = []; 24 + 25 + let searching = false; 26 + 27 + function onInput() { 28 + if ($inputValue.length < 2) { 29 + results = []; 30 + } else { 31 + searching = true; 32 + debounce(async () => { 33 + if ($inputValue.length < 2) return; 34 + 35 + const response = await fetch(`/api/search?q=${$inputValue}`); 36 + const data = await response.json(); 37 + results = data; 38 + 39 + searching = false; 40 + }); 41 + } 42 + } 43 + </script> 44 + 45 + <div class="flex flex-col gap-1"> 46 + <!-- svelte-ignore a11y-label-has-associated-control - $label contains the 'for' attribute --> 47 + <!-- <label use:melt={$label}> 48 + <span class="text-sm font-medium text-accent-900">Find a movie or show:</span> 49 + </label> --> 50 + 51 + <div class="relative"> 52 + <input 53 + use:melt={$input} 54 + class="flex h-10 w-full items-center justify-between rounded-lg border-base-800 bg-black px-3 pr-12 text-base-50 55 + placeholder:text-base-400 focus:border-none focus:outline-none focus:ring-2 focus:ring-accent-500" 56 + placeholder="Find a movie or show" 57 + oninput={onInput} 58 + /> 59 + <div class="absolute right-2 top-1/2 z-10 -translate-y-1/2 text-accent-500"> 60 + {#if $open && $inputValue.length > 1} 61 + <svg 62 + xmlns="http://www.w3.org/2000/svg" 63 + fill="none" 64 + viewBox="0 0 24 24" 65 + stroke-width="1.5" 66 + stroke="currentColor" 67 + class="size-4" 68 + > 69 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" /> 70 + </svg> 71 + {:else} 72 + <svg 73 + xmlns="http://www.w3.org/2000/svg" 74 + fill="none" 75 + viewBox="0 0 24 24" 76 + stroke-width="1.5" 77 + stroke="currentColor" 78 + class="size-4" 79 + > 80 + <path 81 + stroke-linecap="round" 82 + stroke-linejoin="round" 83 + d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" 84 + /> 85 + </svg> 86 + {/if} 87 + </div> 88 + </div> 89 + </div> 90 + {#if $open && $inputValue.length > 1} 91 + <ul 92 + class=" z-50 flex max-h-[300px] flex-col overflow-hidden rounded-lg" 93 + use:melt={$menu} 94 + transition:fly={{ duration: 150, y: -5 }} 95 + > 96 + <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 97 + <div 98 + class="mx-2 flex max-h-full flex-col gap-0 divide-y divide-base-700/50 overflow-y-auto rounded-lg border border-base-700 bg-base-800 px-2 py-2 text-base-50" 99 + tabindex="0" 100 + > 101 + {#each results as item, index (index)} 102 + <li 103 + use:melt={$option({ 104 + value: item, 105 + label: item.title 106 + })} 107 + class="relative w-full cursor-pointer scroll-my-2 px-2 py-2 data-[highlighted]:text-accent-200" 108 + > 109 + <button 110 + onclick={() => { 111 + rateMovieModal.selectedItem = { 112 + ...item, 113 + currentRating: watchedItems.getRating(item)?.rating ?? undefined, 114 + currentReview: watchedItems.getRating(item)?.ratingText ?? undefined, 115 + }; 116 + rateMovieModal.showModal = true; 117 + }} 118 + class="flex w-full items-center justify-start gap-2" 119 + > 120 + <div 121 + class="relative z-20 aspect-[2/3] h-12 w-auto shrink-0 overflow-hidden rounded-md border border-base-800 bg-base-900/50" 122 + > 123 + <img 124 + src="https://image.tmdb.org/t/p/w154{item.poster_path}" 125 + alt="movie poster for {item.title}" 126 + class="size-full object-cover object-center lg:size-full" 127 + /> 128 + </div> 129 + <span class="text-left text-sm font-medium">{item.title}</span> 130 + </button> 131 + </li> 132 + {:else} 133 + <li 134 + class="relative cursor-pointer rounded-md py-1 pl-4 pr-4 text-sm data-[highlighted]:text-accent-200" 135 + > 136 + {#if searching} 137 + Searching... 138 + {:else if $inputValue.length > 1} 139 + No results found 140 + {/if} 141 + </li> 142 + {/each} 143 + </div> 144 + </ul> 145 + {/if}
+30
src/lib/Components/UI/Select.svelte
··· 1 + <script lang="ts"> 2 + import SelectItem from './SelectItem.svelte'; 3 + 4 + type Props = { 5 + items: string[]; 6 + active: string; 7 + onchange?: (active: string) => void; 8 + }; 9 + 10 + let { items, active = $bindable(), onchange }: Props = $props(); 11 + </script> 12 + 13 + <div class="inline-block"> 14 + <ul 15 + class="flex w-fit rounded-full px-3 text-sm font-medium text-base-800 shadow-lg shadow-base-800/5 ring-1 ring-base-900/5 backdrop-blur dark:bg-base-950 dark:text-base-200 dark:ring-white/10" 16 + > 17 + {#each items as item} 18 + <SelectItem 19 + onclick={() => { 20 + if (active == item) return; 21 + active = item; 22 + if (onchange) onchange(item); 23 + }} 24 + isActive={active == item} 25 + > 26 + {item} 27 + </SelectItem> 28 + {/each} 29 + </ul> 30 + </div>
+23
src/lib/Components/UI/SelectItem.svelte
··· 1 + <script lang="ts"> 2 + import { fade } from 'svelte/transition'; 3 + 4 + const { onclick, isActive, children } = $props(); 5 + </script> 6 + 7 + <li> 8 + <button 9 + {onclick} 10 + class={'relative block px-3 py-2 text-xs transition duration-200 ' + 11 + (isActive 12 + ? 'text-accent-500 dark:text-accent-400' 13 + : 'cursor-pointer hover:text-accent-500 dark:hover:text-accent-400')} 14 + > 15 + {@render children()} 16 + {#if isActive} 17 + <span 18 + transition:fade 19 + class="absolute inset-x-1 -bottom-px h-px bg-gradient-to-r from-accent-500/0 via-accent-500/40 to-accent-500/0 dark:from-accent-400/0 dark:via-accent-400/40 dark:to-accent-400/0" 20 + ></span> 21 + {/if} 22 + </button> 23 + </li>
+43
src/lib/Components/User/Avatar.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '$lib/utils'; 3 + 4 + type Props = { 5 + link?: string; 6 + size?: string; 7 + src?: string; 8 + alt?: string; 9 + }; 10 + 11 + const { link, size = 'size-11', src, alt }: Props = $props(); 12 + </script> 13 + 14 + {#snippet avatar()} 15 + <div 16 + class={cn( 17 + 'overflow-hidden rounded-full border border-base-400/50 bg-base-100 dark:border-base-700 dark:bg-base-800', 18 + size 19 + )} 20 + > 21 + {#if src} 22 + <img loading="lazy" class="h-full w-full object-cover" {src} {alt} /> 23 + {:else} 24 + <svg 25 + class="size-full text-base-300 dark:text-base-600" 26 + fill="currentColor" 27 + viewBox="0 0 24 24" 28 + > 29 + <path 30 + d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" 31 + /> 32 + </svg> 33 + {/if} 34 + </div> 35 + {/snippet} 36 + 37 + {#if link} 38 + <a href={link} class="inline-block" target="_blank" rel="noopener noreferrer nofollow"> 39 + {@render avatar()} 40 + </a> 41 + {:else} 42 + {@render avatar()} 43 + {/if}
+58
src/lib/Components/User/Profile.svelte
··· 1 + <script lang="ts"> 2 + import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 3 + import Avatar from './Avatar.svelte'; 4 + import { cn } from '$lib/utils'; 5 + 6 + let { profile }: { profile: ProfileViewDetailed } = $props(); 7 + </script> 8 + 9 + {#if profile} 10 + <div class="mx-auto max-w-full sm:max-w-2xl sm:py-6"> 11 + <div> 12 + {#if profile.banner} 13 + <img 14 + class="aspect-[3/1] w-full border-b border-base-800 object-cover sm:rounded-xl sm:border" 15 + src={profile.banner} 16 + alt="" 17 + /> 18 + {:else} 19 + <div class="aspect-[8/1] w-full"></div> 20 + {/if} 21 + </div> 22 + <div 23 + class={cn( 24 + profile.banner ? '-mt-11' : '-mt-8', 25 + 'flex max-w-full items-end space-x-5 px-4 sm:-mt-16 sm:px-6 lg:px-8' 26 + )} 27 + > 28 + <Avatar src={profile.avatar} size="size-24 sm:size-32" /> 29 + <div 30 + class="flex min-w-0 flex-1 flex-row sm:flex-row sm:items-center sm:justify-end sm:space-x-6 sm:pb-1" 31 + > 32 + <div 33 + class={cn( 34 + profile.banner ? 'mt-4 sm:mt-0' : '-mt-[4.5rem] sm:-mt-[6.5rem]', 35 + 'flex min-w-0 max-w-full flex-1 flex-col items-baseline' 36 + )} 37 + > 38 + <h1 39 + class="max-w-full truncate text-lg font-bold text-base-900 dark:text-base-100 sm:text-xl" 40 + > 41 + {profile.displayName || profile.handle} 42 + </h1> 43 + <div class="truncate text-xs text-base-900 dark:text-base-400 sm:text-sm"> 44 + @{profile.handle} 45 + </div> 46 + </div> 47 + <div class="mt-6 hidden flex-row justify-stretch space-x-4 space-y-0"> 48 + <button 49 + type="button" 50 + class="inline-flex justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-base-900 shadow-sm ring-1 ring-inset ring-base-300 hover:bg-base-50" 51 + > 52 + <span>Call</span> 53 + </button> 54 + </div> 55 + </div> 56 + </div> 57 + </div> 58 + {/if}
+61
src/lib/Components/User/User.svelte
··· 1 + <script lang="ts"> 2 + import { showLoginModal } from '$lib/state/modals.svelte'; 3 + import { cn } from '$lib/utils'; 4 + 5 + type Props = { 6 + user?: { 7 + avatar?: string; 8 + handle?: string; 9 + }; 10 + }; 11 + 12 + const { user }: Props = $props(); 13 + </script> 14 + 15 + {#snippet renderUser()} 16 + <span class="sr-only">Open user menu</span> 17 + <div 18 + class={cn( 19 + 'size-8 overflow-hidden rounded-full border border-base-400/50 bg-base-100 transition-colors duration-100 hover:border-[1.5px] hover:border-accent-400 dark:border-base-700 dark:bg-base-800', 20 + false 21 + ? 'border-accent-300/50 bg-accent-100/50 dark:border-accent-500/50 dark:bg-accent-800/50' 22 + : '' 23 + )} 24 + > 25 + {#if user?.avatar} 26 + <img loading="lazy" class="h-full w-full object-cover" src={user.avatar} alt="" /> 27 + {:else} 28 + <svg 29 + class={cn( 30 + 'size-full text-base-300 dark:text-base-600', 31 + user ? 'text-accent-300 dark:text-accent-500' : '' 32 + )} 33 + fill="currentColor" 34 + viewBox="0 0 24 24" 35 + > 36 + <path 37 + d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" 38 + /> 39 + </svg> 40 + {/if} 41 + </div> 42 + {/snippet} 43 + 44 + <li class="relative pt-4"> 45 + {#if user} 46 + <a href="/user/{user.handle}"> 47 + {@render renderUser()} 48 + </a> 49 + {:else} 50 + <button 51 + type="button" 52 + class="flex cursor-pointer items-center hover:opacity-80" 53 + id="user-menu-button" 54 + aria-expanded="false" 55 + aria-haspopup="true" 56 + onclick={() => showLoginModal.toggle()} 57 + > 58 + {@render renderUser()} 59 + </button> 60 + {/if} 61 + </li>
+133
src/lib/Components/Utils/VideoPlayer.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '$lib/utils'; 3 + import Plyr from 'plyr'; 4 + import { videoPlayer } from '$lib/state/video.svelte'; 5 + 6 + const { class: className }: { class?: string } = $props(); 7 + 8 + $effect(() => { 9 + const player = new Plyr('.js-player', { 10 + settings: ['captions', 'quality', 'loop', 'speed'], 11 + controls: [ 12 + 'play-large', 13 + 'play', 14 + 'progress', 15 + 'current-time', 16 + 'volume', 17 + 'settings', 18 + 'download', 19 + 'fullscreen' 20 + ] 21 + }); 22 + 23 + // set the video player to the id 24 + if (videoPlayer.id) { 25 + player.source = { 26 + type: 'video', 27 + sources: [ 28 + { 29 + src: videoPlayer.id, 30 + type: 'video/youtube' 31 + } 32 + ] 33 + }; 34 + } 35 + 36 + // when loaded play the video and go fullscreen 37 + player.on('ready', () => { 38 + player.play(); 39 + //player.fullscreen.enter(); 40 + }); 41 + 42 + return () => { 43 + player.destroy(); 44 + }; 45 + }); 46 + 47 + let glow = 50; 48 + </script> 49 + 50 + <svelte:head> 51 + {#if videoPlayer.showing && videoPlayer.id} 52 + <link rel="stylesheet" href="/plyr.css" /> 53 + {/if} 54 + </svelte:head> 55 + 56 + <svelte:window 57 + onkeydown={(e) => { 58 + if (e.key === 'Escape' && videoPlayer.showing) { 59 + videoPlayer.hide(); 60 + } 61 + }} 62 + /> 63 + 64 + {#key videoPlayer.id} 65 + {#if videoPlayer.showing && videoPlayer.id} 66 + <div class="fixed inset-0 z-[100] flex h-screen w-screen items-center justify-center"> 67 + <button 68 + onclick={() => videoPlayer.hide()} 69 + class="absolute inset-0 bg-black/70 backdrop-blur-sm" 70 + > 71 + <span class="sr-only">Close</span> 72 + </button> 73 + 74 + <div 75 + class={cn( 76 + 'aspect-video relative mx-4 max-h-screen w-full overflow-hidden rounded-xl border border-black bg-white object-cover dark:border-white/10 dark:bg-white/5 sm:mx-20', 77 + className 78 + )} 79 + style="filter: url(#blur); width: 100%;" 80 + > 81 + <div class=""> 82 + <div 83 + id="player" 84 + class="h-full w-full overflow-hidden rounded-xl object-cover font-semibold text-black dark:text-white" 85 + > 86 + <div 87 + class="js-player plyr__video-embed" 88 + id="player" 89 + data-plyr-provider="youtube" 90 + data-plyr-embed-id={videoPlayer.id} 91 + ></div> 92 + </div> 93 + </div> 94 + </div> 95 + 96 + <button 97 + onclick={() => { 98 + videoPlayer.hide(); 99 + }} 100 + class="absolute right-2 top-2 z-20 rounded-full border border-white/10 bg-white/5 p-2 backdrop-blur-sm" 101 + > 102 + <svg 103 + xmlns="http://www.w3.org/2000/svg" 104 + viewBox="0 0 24 24" 105 + fill="currentColor" 106 + class="size-6" 107 + > 108 + <path 109 + fill-rule="evenodd" 110 + d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" 111 + clip-rule="evenodd" 112 + /> 113 + </svg> 114 + 115 + <span class="sr-only">Close</span> 116 + </button> 117 + </div> 118 + {/if} 119 + {/key} 120 + 121 + <svg width="0" height="0"> 122 + <filter id="blur" y="-50%" x="-50%" width="300%" height="300%"> 123 + <feGaussianBlur in="SourceGraphic" stdDeviation={glow} result="blurred" /> 124 + <feColorMatrix type="saturate" in="blurred" values="3" /> 125 + <feComposite in="SourceGraphic" operator="over" /> 126 + </filter> 127 + </svg> 128 + 129 + <style> 130 + * { 131 + --plyr-color-main: #38bdf8; 132 + } 133 + </style>
-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 - }
+60
src/routes/[kind=item]/[id]/+page.server.ts
··· 1 + import { getRecentRecordsForItem } from '$lib/db.js'; 2 + import { 3 + getCast, 4 + getDetails, 5 + getRecommendations, 6 + getTrailer, 7 + getWatchProviders 8 + } from '$lib/server/movies'; 9 + import { error } from '@sveltejs/kit'; 10 + 11 + /** @type {import('./$types').PageServerLoad} */ 12 + export async function load(event) { 13 + const id = parseInt(event.params.id.split('-')[0]); 14 + const kind = event.params.kind; 15 + 16 + if (!id) { 17 + return error(404, 'Not found'); 18 + } 19 + const resultPromise = getDetails(id, kind); 20 + 21 + const trailerPromise = getTrailer(id, kind); 22 + 23 + const recommendationsPromise = getRecommendations(id, kind); 24 + 25 + const watchProvidersPromise = getWatchProviders(id, kind); 26 + 27 + const ratingsPromise = getRecentRecordsForItem({ 28 + ref: 'tmdb:' + (kind === 'movie' ? 'm' : 's'), 29 + value: id.toString() 30 + }); 31 + 32 + const castPromise = getCast(id, kind); 33 + 34 + const [result, trailer, recommendations, watchProviders, ratings, cast] = await Promise.all([ 35 + resultPromise, 36 + trailerPromise, 37 + recommendationsPromise, 38 + watchProvidersPromise, 39 + ratingsPromise, 40 + castPromise 41 + ]); 42 + 43 + if (!result || result.success === false) { 44 + return error(404, 'Not found'); 45 + } 46 + 47 + return { 48 + item: { 49 + ...result, 50 + title: result.title ?? result.name, 51 + ref: (kind === 'movie' ? 'tmdb:m-' : 'tmdb:s-') + id 52 + }, 53 + trailer, 54 + recommendations, 55 + kind, 56 + watchProviders, 57 + ratings, 58 + cast 59 + }; 60 + }
+196
src/routes/[kind=item]/[id]/+page.svelte
··· 1 + <script lang="ts"> 2 + import { type PageData } from './$types'; 3 + import { cn, nameToId } from '$lib/utils'; 4 + import { rateMovieModal } from '$lib/state/modals.svelte'; 5 + import { videoPlayer } from '$lib/state/video.svelte'; 6 + import { watchedItems } from '$lib/state/user.svelte'; 7 + 8 + import Container from '$lib/Components/Layout/Container.svelte'; 9 + import ItemsList from '$lib/Components/Items/ItemsList.svelte'; 10 + import ReviewList from '$lib/Components/Items/ReviewList.svelte'; 11 + import Avatar from '$lib/Components/User/Avatar.svelte'; 12 + import { page } from '$app/stores'; 13 + 14 + let { data }: { data: PageData } = $props(); 15 + </script> 16 + 17 + <svelte:head> 18 + <title>{data.item.title} | skywatched</title> 19 + 20 + <meta 21 + name="description" 22 + content={`Rate and review "${data.item.title}" on skywatched`} 23 + /> 24 + 25 + <meta property="og:url" content={$page.url.href} /> 26 + <meta property="og:type" content="website" /> 27 + <meta 28 + property="og:title" 29 + content="{data.item.title} | skywatched.app" 30 + /> 31 + <meta 32 + property="og:description" 33 + content={`Rate and review "${data.item.title}" on skywatched`} 34 + /> 35 + <meta property="og:image" content="{$page.url.href}/og.png" /> 36 + 37 + <meta name="twitter:card" content="summary_large_image" /> 38 + <meta property="twitter:domain" content="skywatched.app" /> 39 + <meta property="twitter:url" content={$page.url.href} /> 40 + <meta 41 + name="twitter:title" 42 + content="{data.item.title} | skywatched.app" 43 + /> 44 + <meta 45 + name="twitter:description" 46 + content={`Rate and review "${data.item.title}" on skywatched`} 47 + /> 48 + <meta name="twitter:image" content="{$page.url.href}/og.png" /> 49 + </svelte:head> 50 + 51 + {#snippet buttons()} 52 + {#if data.user && !watchedItems.hasRated(data.item)} 53 + <button 54 + class={cn( 55 + 'inline-flex items-center gap-2 rounded-md bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all duration-75 hover:bg-white/20 ', 56 + watchedItems.hasRated(data.item) 57 + ? 'bg-green-500/10 text-green-400 hover:bg-green-500/20' 58 + : '' 59 + )} 60 + onclick={() => { 61 + rateMovieModal.show({ 62 + ...data.item, 63 + }); 64 + }} 65 + > 66 + rate {data.kind === 'movie' ? 'movie' : 'show'} 67 + </button> 68 + {/if} 69 + {#if data.trailer} 70 + <button 71 + onclick={() => videoPlayer.show(data.trailer ?? '')} 72 + type="button" 73 + class="inline-flex w-fit items-center gap-x-1.5 rounded-md border border-accent-500/30 bg-accent-700/20 px-3 py-2 text-sm font-semibold text-accent-400 shadow-sm transition-all duration-100 hover:bg-accent-700/30 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600" 74 + > 75 + <svg 76 + xmlns="http://www.w3.org/2000/svg" 77 + viewBox="0 0 24 24" 78 + fill="currentColor" 79 + class="-ml-0.5 size-5" 80 + > 81 + <path 82 + d="M4.5 4.5a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h8.25a3 3 0 0 0 3-3v-9a3 3 0 0 0-3-3H4.5ZM19.94 18.75l-2.69-2.69V7.94l2.69-2.69c.944-.945 2.56-.276 2.56 1.06v11.38c0 1.336-1.616 2.005-2.56 1.06Z" 83 + /> 84 + </svg> 85 + 86 + Trailer 87 + </button> 88 + {/if} 89 + {/snippet} 90 + 91 + <img 92 + src="https://image.tmdb.org/t/p/w780{data.item.backdrop_path}" 93 + alt="" 94 + class="fixed h-full w-full object-cover object-center opacity-20" 95 + /> 96 + <div class="fixed inset-0 h-full w-full bg-black/50"></div> 97 + 98 + <Container class="relative z-10 pb-8 pt-4"> 99 + <div class="flex gap-4 px-4 pt-8"> 100 + <img 101 + src="https://image.tmdb.org/t/p/w500{data.item.poster_path}" 102 + alt="" 103 + class="poster h-36 w-24 shrink-0 rounded-lg border border-white/10 sm:h-64 sm:w-44" 104 + style:--name={`poster-${data.item.ref}`} 105 + /> 106 + <div class="flex flex-col gap-4"> 107 + <div 108 + class="title max-w-xl text-3xl font-semibold text-white sm:text-4xl" 109 + style:--name={`title-${data.item.ref}`} 110 + > 111 + {data.item.title ?? data.item.name} 112 + </div> 113 + 114 + {#if data.settings?.streaming_region?.code && data.watchProviders[data.settings.streaming_region.code]?.flatrate} 115 + <div class="mt-2 text-sm text-white sm:mt-4"> 116 + <div class="mb-2 flex flex-wrap gap-4 text-xs font-medium"> 117 + stream on 118 + <span class="flex items-center gap-2 text-base-400" 119 + >from <a 120 + href="https://www.justwatch.com" 121 + target="_blank" 122 + class="text-base-300 hover:text-accent-300" 123 + > 124 + <img src="/justwatch_logo.svg" alt="justwatch" class="h-3" /> 125 + </a></span 126 + > 127 + </div> 128 + <a 129 + href={data.watchProviders[data.settings.streaming_region.code].link} 130 + target="_blank" 131 + class="flex flex-wrap gap-2" 132 + > 133 + {#each data.watchProviders[data.settings.streaming_region.code].flatrate as provider} 134 + <img 135 + src="https://image.tmdb.org/t/p/w500{provider.logo_path}" 136 + alt={provider.provider_name} 137 + class="size-8 rounded-md border border-base-800 md:size-12" 138 + /> 139 + {/each} 140 + </a> 141 + </div> 142 + {/if} 143 + <div class="hidden gap-2 sm:flex"> 144 + {@render buttons()} 145 + </div> 146 + </div> 147 + </div> 148 + 149 + <div class="px-4 pt-4 text-sm text-white"> 150 + <div class="mb-4 flex gap-2 sm:hidden"> 151 + {@render buttons()} 152 + </div> 153 + <div class="mb-4 max-w-2xl text-sm text-white"> 154 + <div class="mb-2 text-lg font-semibold">overview</div> 155 + {data.item.overview} 156 + </div> 157 + </div> 158 + 159 + {#if data.ratings.length > 0} 160 + <div class="mt-8 px-4 text-lg font-semibold">reviews</div> 161 + 162 + <ReviewList reviews={data.ratings} showMovieDetails={false} class="" /> 163 + {/if} 164 + 165 + {#if data.recommendations.length > 0} 166 + <div class="px-4 pt-4 text-sm text-white"> 167 + <div class="mb-2 text-lg font-semibold">recommendations</div> 168 + 169 + <ItemsList items={data.recommendations} showMark={!!data.user} /> 170 + </div> 171 + {/if} 172 + 173 + {#if data.cast.length > 0} 174 + <div class="px-4 pb-8 pt-4 text-sm text-white"> 175 + <div class="mb-2 text-lg font-semibold">cast</div> 176 + 177 + <div class={cn('flex gap-x-6 overflow-x-auto')}> 178 + {#each data.cast as castMember} 179 + <a 180 + href={`/cast/${castMember.id}-${nameToId(castMember.name)}`} 181 + class="flex flex-col items-center gap-1" 182 + > 183 + <Avatar 184 + src={castMember.profile_path 185 + ? 'https://image.tmdb.org/t/p/w500' + castMember.profile_path 186 + : undefined} 187 + size="size-32" 188 + /> 189 + <div class="text-center text-xs font-medium">{castMember.name}</div> 190 + <div class="text-center text-xs text-base-400">{castMember.character}</div> 191 + </a> 192 + {/each} 193 + </div> 194 + </div> 195 + {/if} 196 + </Container>
-71
src/routes/[kind]/[id]/+page.server.ts
··· 1 - import { getRecentRecordsForItem } from '$lib/db.js'; 2 - import { 3 - getCast, 4 - getDetails, 5 - getRecommendations, 6 - getTrailer, 7 - getWatchProviders 8 - } from '$lib/server/movies'; 9 - import { error } from '@sveltejs/kit'; 10 - 11 - /** @type {import('./$types').PageServerLoad} */ 12 - export async function load(event) { 13 - const id = parseInt(event.params.id.split('-')[0]); 14 - const kind = event.params.kind; 15 - 16 - if (kind !== 'movie' && kind !== 'tv') { 17 - return error(404, 'Not found'); 18 - } 19 - 20 - if (!id) { 21 - return error(404, 'Not found'); 22 - } 23 - const resultPromise = getDetails(id, kind); 24 - 25 - const trailerPromise = getTrailer(id, kind); 26 - 27 - const recommendationsPromise = getRecommendations(id, kind); 28 - 29 - const watchProvidersPromise = getWatchProviders(id, kind); 30 - 31 - const ratingsPromise = getRecentRecordsForItem({ 32 - ref: 'tmdb:' + (kind === 'movie' ? 'm' : 's'), 33 - value: id.toString() 34 - }); 35 - 36 - const castPromise = getCast(id, kind); 37 - 38 - const [result, trailer, recommendations, watchProviders, ratings, cast] = await Promise.all([ 39 - resultPromise, 40 - trailerPromise, 41 - recommendationsPromise, 42 - watchProvidersPromise, 43 - ratingsPromise, 44 - castPromise 45 - ]); 46 - 47 - if (!result || result.success === false) { 48 - return error(404, 'Not found'); 49 - } 50 - 51 - return { 52 - result: { 53 - ...result, 54 - movieId: kind === 'movie' ? id : undefined, 55 - showId: kind === 'tv' ? id : undefined 56 - }, 57 - trailer, 58 - // @ts-expect-error - TODO: fix this 59 - recommendations: recommendations.map((item) => { 60 - if (kind === 'movie') { 61 - return { ...item, movieId: item.id }; 62 - } else { 63 - return { ...item, showId: item.id }; 64 - } 65 - }), 66 - kind, 67 - watchProviders, 68 - ratings, 69 - cast 70 - }; 71 - }
-200
src/routes/[kind]/[id]/+page.svelte
··· 1 - <script lang="ts"> 2 - import { type PageData } from './$types'; 3 - import { cn, nameToId } from '$lib/utils'; 4 - import { rateMovieModal, videoPlayer, watchedItems } from '$lib/state.svelte'; 5 - 6 - import Container from '$lib/Components/Container.svelte'; 7 - import ItemsList from '$lib/Components/ItemsList.svelte'; 8 - import ReviewList from '$lib/Components/ReviewList.svelte'; 9 - import Avatar from '$lib/Components/Avatar.svelte'; 10 - import { page } from '$app/stores'; 11 - 12 - let { data }: { data: PageData } = $props(); 13 - </script> 14 - 15 - <svelte:head> 16 - <title>{data.result.title ?? data.result.name ?? ''} | skywatched</title> 17 - 18 - <meta 19 - name="description" 20 - content={`Rate and review "${data.result.title ?? data.result.name ?? ''}" on skywatched`} 21 - /> 22 - 23 - <meta property="og:url" content={$page.url.href} /> 24 - <meta property="og:type" content="website" /> 25 - <meta 26 - property="og:title" 27 - content="{data.result.title ?? data.result.name ?? ''} | skywatched.app" 28 - /> 29 - <meta 30 - property="og:description" 31 - content={`Rate and review "${data.result.title ?? data.result.name ?? ''}" on skywatched`} 32 - /> 33 - <meta property="og:image" content="{$page.url.href}/og.png" /> 34 - 35 - <meta name="twitter:card" content="summary_large_image" /> 36 - <meta property="twitter:domain" content="skywatched.app" /> 37 - <meta property="twitter:url" content={$page.url.href} /> 38 - <meta 39 - name="twitter:title" 40 - content="{data.result.title ?? data.result.name ?? ''} | skywatched.app" 41 - /> 42 - <meta 43 - name="twitter:description" 44 - content={`Rate and review "${data.result.title ?? data.result.name ?? ''}" on skywatched`} 45 - /> 46 - <meta name="twitter:image" content="{$page.url.href}/og.png" /> 47 - </svelte:head> 48 - 49 - {#snippet buttons()} 50 - {#if data.user && !watchedItems.hasRated(data.result)} 51 - <button 52 - class={cn( 53 - 'inline-flex items-center gap-2 rounded-md bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm transition-all duration-75 hover:bg-white/20 ', 54 - watchedItems.hasRated(data.result) 55 - ? 'bg-green-500/10 text-green-400 hover:bg-green-500/20' 56 - : '' 57 - )} 58 - onclick={() => { 59 - rateMovieModal.show({ 60 - movieId: data.result.movieId, 61 - showId: data.result.showId, 62 - kind: data.result.movieId ? 'movie' : 'tv', 63 - name: data.result.original_title ?? data.result.original_name, 64 - posterPath: data.result.poster_path, 65 - currentRating: watchedItems.getRating(data.result)?.rating ?? 0, 66 - currentReview: watchedItems.getRating(data.result)?.ratingText ?? '' 67 - }); 68 - }} 69 - > 70 - rate {data.kind === 'movie' ? 'movie' : 'show'} 71 - </button> 72 - {/if} 73 - {#if data.trailer} 74 - <button 75 - onclick={() => videoPlayer.show(data.trailer ?? '')} 76 - type="button" 77 - class="inline-flex w-fit items-center gap-x-1.5 rounded-md border border-accent-500/30 bg-accent-700/20 px-3 py-2 text-sm font-semibold text-accent-400 shadow-sm transition-all duration-100 hover:bg-accent-700/30 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600" 78 - > 79 - <svg 80 - xmlns="http://www.w3.org/2000/svg" 81 - viewBox="0 0 24 24" 82 - fill="currentColor" 83 - class="-ml-0.5 size-5" 84 - > 85 - <path 86 - d="M4.5 4.5a3 3 0 0 0-3 3v9a3 3 0 0 0 3 3h8.25a3 3 0 0 0 3-3v-9a3 3 0 0 0-3-3H4.5ZM19.94 18.75l-2.69-2.69V7.94l2.69-2.69c.944-.945 2.56-.276 2.56 1.06v11.38c0 1.336-1.616 2.005-2.56 1.06Z" 87 - /> 88 - </svg> 89 - 90 - Trailer 91 - </button> 92 - {/if} 93 - {/snippet} 94 - 95 - <img 96 - src="https://image.tmdb.org/t/p/w780{data.result.backdrop_path}" 97 - alt="" 98 - class="fixed h-full w-full object-cover object-center opacity-20" 99 - /> 100 - <div class="fixed inset-0 h-full w-full bg-black/50"></div> 101 - 102 - <Container class="relative z-10 pb-8 pt-4"> 103 - <div class="flex gap-4 px-4 pt-8"> 104 - <img 105 - src="https://image.tmdb.org/t/p/w500{data.result.poster_path}" 106 - alt="" 107 - class="poster h-36 w-24 shrink-0 rounded-lg border border-white/10 sm:h-64 sm:w-44" 108 - style:--name={`poster-${data.result.id}`} 109 - /> 110 - <div class="flex flex-col gap-4"> 111 - <div 112 - class="title max-w-xl text-3xl font-semibold text-white sm:text-4xl" 113 - style:--name={`title-${data.result.id}`} 114 - > 115 - {data.result.title ?? data.result.name} 116 - </div> 117 - 118 - {#if data.settings?.streaming_region?.code && data.watchProviders[data.settings.streaming_region.code]?.flatrate} 119 - <div class="mt-2 text-sm text-white sm:mt-4"> 120 - <div class="mb-2 flex flex-wrap gap-4 text-xs font-medium"> 121 - stream on 122 - <span class="flex items-center gap-2 text-base-400" 123 - >from <a 124 - href="https://www.justwatch.com" 125 - target="_blank" 126 - class="text-base-300 hover:text-accent-300" 127 - > 128 - <img src="/justwatch_logo.svg" alt="justwatch" class="h-3" /> 129 - </a></span 130 - > 131 - </div> 132 - <a 133 - href={data.watchProviders[data.settings.streaming_region.code].link} 134 - target="_blank" 135 - class="flex flex-wrap gap-2" 136 - > 137 - {#each data.watchProviders[data.settings.streaming_region.code].flatrate as provider} 138 - <img 139 - src="https://image.tmdb.org/t/p/w500{provider.logo_path}" 140 - alt={provider.provider_name} 141 - class="size-8 rounded-md border border-base-800 md:size-12" 142 - /> 143 - {/each} 144 - </a> 145 - </div> 146 - {/if} 147 - <div class="hidden gap-2 sm:flex"> 148 - {@render buttons()} 149 - </div> 150 - </div> 151 - </div> 152 - 153 - <div class="px-4 pt-4 text-sm text-white"> 154 - <div class="mb-4 flex gap-2 sm:hidden"> 155 - {@render buttons()} 156 - </div> 157 - <div class="mb-4 max-w-2xl text-sm text-white"> 158 - <div class="mb-2 text-lg font-semibold">overview</div> 159 - {data.result.overview} 160 - </div> 161 - </div> 162 - 163 - {#if data.ratings.length > 0} 164 - <div class="mt-8 px-4 text-lg font-semibold">reviews</div> 165 - 166 - <ReviewList reviews={data.ratings} showMovieDetails={false} class="" /> 167 - {/if} 168 - 169 - {#if data.recommendations.length > 0} 170 - <div class="px-4 pt-4 text-sm text-white"> 171 - <div class="mb-2 text-lg font-semibold">recommendations</div> 172 - 173 - <ItemsList items={data.recommendations} showMark={!!data.user} /> 174 - </div> 175 - {/if} 176 - 177 - {#if data.cast.length > 0} 178 - <div class="px-4 pb-8 pt-4 text-sm text-white"> 179 - <div class="mb-2 text-lg font-semibold">cast</div> 180 - 181 - <div class={cn('flex gap-x-6 overflow-x-auto')}> 182 - {#each data.cast as castMember} 183 - <a 184 - href={`/cast/${castMember.id}-${nameToId(castMember.name)}`} 185 - class="flex flex-col items-center gap-1" 186 - > 187 - <Avatar 188 - src={castMember.profile_path 189 - ? 'https://image.tmdb.org/t/p/w500' + castMember.profile_path 190 - : undefined} 191 - size="size-32" 192 - /> 193 - <div class="text-center text-xs font-medium">{castMember.name}</div> 194 - <div class="text-center text-xs text-base-400">{castMember.character}</div> 195 - </a> 196 - {/each} 197 - </div> 198 - </div> 199 - {/if} 200 - </Container>
+45
src/routes/api/crosspost/+server.ts
··· 5 5 6 6 import imagemin from 'imagemin'; 7 7 import imageminWebp from 'imagemin-webp'; 8 + import { REL_COLLECTION } from '$lib'; 8 9 9 10 export const POST: RequestHandler = async ({ request, locals, fetch }) => { 10 11 const user = locals.user; ··· 133 134 } 134 135 }; 135 136 137 + // post to bluesky 136 138 await agent.com.atproto.repo.putRecord(record); 139 + 140 + const crosspostUri = `at://${did}/app.bsky.feed.post/${rkey}`; 141 + 142 + const updatedRecord: { 143 + repo: string; 144 + collection: string; 145 + rkey: string; 146 + record: { 147 + item: { 148 + ref: string; 149 + value: string; 150 + }; 151 + rating?: { value: number; createdAt: string }; 152 + note?: { 153 + value: string; 154 + createdAt: string; 155 + updatedAt: string; 156 + }; 157 + from?: string; 158 + crosspost?: { 159 + uri: string; 160 + likes?: number; 161 + reposts?: number; 162 + replies?: number; 163 + }; 164 + }; 165 + } = { 166 + repo: did, 167 + collection: REL_COLLECTION, 168 + rkey, 169 + record: { 170 + item: reviewRecord.record.item, 171 + rating: reviewRecord.record.rating, 172 + from: 'skywatched', 173 + note: reviewRecord.record.note, 174 + crosspost: { 175 + uri: crosspostUri 176 + } 177 + } 178 + }; 179 + 180 + // update the main record with the crosspost uri 181 + await agent.com.atproto.repo.putRecord(updatedRecord); 137 182 138 183 return json({ status: 'rated' }); 139 184 };
-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 - };
+4 -4
src/routes/api/rate/+server.ts
··· 12 12 const body = await request.json(); 13 13 const rating = body.rating; 14 14 const review = body.review; 15 - const kind = body.kind; 16 - const id = body.id; 15 + const ref = body.ref; 16 + 17 17 const did = user.did; 18 18 19 19 const rkey = TID.nextStr(); ··· 41 41 rkey, 42 42 record: { 43 43 item: { 44 - ref: `tmdb:${kind === 'movie' ? 'm' : 's'}`, 45 - value: id.toString() 44 + ref: ref.split('-')[0], 45 + value: ref.split('-')[1] 46 46 }, 47 47 rating: { value: rating * 2, createdAt: new Date().toISOString() }, 48 48 from: 'skywatched'
+26
src/routes/api/rated/+server.ts
··· 1 + import { error, json, type RequestHandler } from '@sveltejs/kit'; 2 + import { getRecentRecordOfUser, type MainRecord } from '$lib/db'; 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: MainRecord[] = await getRecentRecordOfUser({ did }); 12 + 13 + const transformedItems = items.map((item) => { 14 + return [ 15 + item.record.item.ref + '-' + item.record.item.value, 16 + { 17 + id: parseInt(item.record.item.value ?? '0'), 18 + rating: (item.record.rating?.value ?? 0) / 2, 19 + ratingText: item.record.note?.value, 20 + updatedAt: item.record.note?.updatedAt ?? item.record.rating?.createdAt 21 + } 22 + ]; 23 + }); 24 + 25 + return json(transformedItems); 26 + };
+1 -1
src/routes/api/review/+server.ts
··· 52 52 53 53 const rating = body.rating; 54 54 const review = body.review; 55 - const kind = body.kind; 55 + const kind = body.media_type; 56 56 const id = body.id; 57 57 58 58 const record: {
+4 -5
src/routes/cast/[id]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import Avatar from '$lib/Components/Avatar.svelte'; 3 - import BaseHeadTags from '$lib/Components/BaseHeadTags.svelte'; 4 - import Container from '$lib/Components/Container.svelte'; 5 - import ItemsGrid from '$lib/Components/ItemsGrid.svelte'; 6 - import { onMount } from 'svelte'; 2 + import Avatar from '$lib/Components/User/Avatar.svelte'; 3 + import BaseHeadTags from '$lib/Components/Layout/BaseHeadTags.svelte'; 4 + import Container from '$lib/Components/Layout/Container.svelte'; 5 + import ItemsGrid from '$lib/Components/Items/ItemsGrid.svelte'; 7 6 8 7 let { data } = $props(); 9 8
+2 -2
src/routes/review/[uri]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import ReviewCard from '$lib/Components/ReviewCard.svelte'; 3 - import Container from '$lib/Components/Container.svelte'; 2 + import ReviewCard from '$lib/Components/Items/ReviewCard.svelte'; 3 + import Container from '$lib/Components/Layout/Container.svelte'; 4 4 import { page } from '$app/stores'; 5 5 6 6 let { data } = $props();
+4 -4
src/routes/user/[handle]/+page.svelte
··· 1 1 <script lang="ts"> 2 - import BaseHeadTags from '$lib/Components/BaseHeadTags.svelte'; 3 - import Container from '$lib/Components/Container.svelte'; 4 - import Profile from '$lib/Components/Profile.svelte'; 5 - import ReviewList from '$lib/Components/ReviewList.svelte'; 2 + import BaseHeadTags from '$lib/Components/Layout/BaseHeadTags.svelte'; 3 + import Container from '$lib/Components/Layout/Container.svelte'; 4 + import Profile from '$lib/Components/User/Profile.svelte'; 5 + import ReviewList from '$lib/Components/Items/ReviewList.svelte'; 6 6 7 7 let { data } = $props(); 8 8 </script>
+17
src/lib/Components/Utils/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/Utils/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/Utils/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/Utils/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/Utils/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/Utils/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 + }
+72
src/routes/[kind=item]/[id]/og.png/+server.ts
··· 1 + // src/routes/og/+server.ts 2 + import { getDetails } from '$lib/server/movies'; 3 + import { ImageResponse } from '@ethercorps/sveltekit-og'; 4 + import { error, type RequestHandler } from '@sveltejs/kit'; 5 + 6 + const template = (data: { 7 + backdrop_path: string; 8 + poster_path: string; 9 + title?: string; 10 + name?: string; 11 + }) => { 12 + return ` 13 + <div tw="bg-zinc-900 flex flex-col w-full h-full items-center justify-center"> 14 + 15 + <div tw="flex absolute bottom-0 left-0 right-0 top-0 bg-sky-400"> 16 + <img src="https://image.tmdb.org/t/p/w780${data.backdrop_path}" alt="" class="flex h-full w-full rounded-xl opacity-50" /> 17 + 18 + <div tw="flex absolute h-full w-full bg-black/80"> 19 + </div> 20 + </div> 21 + 22 + <div tw="flex flex-row w-full py-8 px-16 items-center justify-start"> 23 + <div tw="flex h-auto aspect-[3/2] w-72"> 24 + <img src="https://image.tmdb.org/t/p/w500${data.poster_path}" alt="" 25 + class="flex h-full w-full rounded-xl border-2 border-zinc-800" style="border-radius: 16px; border-width: 1px; border-color: #3f3f46;" /> 26 + </div> 27 + 28 + <h2 tw="flex flex-col text-7xl font-bold text-zinc-100 text-left px-12 max-w-3xl"> 29 + <span tw="flex tracking-tight">${data.title ?? data.name}</span> 30 + </h2> 31 + </div> 32 + 33 + <div tw="flex text-4xl text-sky-400 items-end justify-end w-full px-8"> 34 + <div tw="flex"> 35 + rate on skywatched.app 36 + </div> 37 + </div> 38 + </div>`; 39 + }; 40 + 41 + const host = import.meta.env.DEV ? 'http://localhost:5173' : 'https://skywatched.app'; 42 + const fontPath = `fonts/inter-latin-ext-400-normal.woff`; 43 + const fontFile = await fetch(`${host}/${fontPath}`); 44 + const fontData: ArrayBuffer = await fontFile.arrayBuffer(); 45 + 46 + export const GET: RequestHandler = async ({ params }) => { 47 + const id = parseInt(params.id?.split('-')[0] ?? ''); 48 + const kind = params.kind; 49 + 50 + if (kind !== 'movie' && kind !== 'tv') { 51 + return error(404, 'Not found'); 52 + } 53 + 54 + if (!id) { 55 + return error(404, 'Not found'); 56 + } 57 + const result = await getDetails(id, kind); 58 + 59 + return new ImageResponse( 60 + template(result), 61 + { 62 + fonts: [ 63 + { 64 + name: 'Inter Latin', 65 + data: fontData, 66 + weight: 400 67 + } 68 + ] 69 + }, 70 + {} 71 + ); 72 + };
-72
src/routes/[kind]/[id]/og.png/+server.ts
··· 1 - // src/routes/og/+server.ts 2 - import { getDetails } from '$lib/server/movies'; 3 - import { ImageResponse } from '@ethercorps/sveltekit-og'; 4 - import { error, type RequestHandler } from '@sveltejs/kit'; 5 - 6 - const template = (data: { 7 - backdrop_path: string; 8 - poster_path: string; 9 - title?: string; 10 - name?: string; 11 - }) => { 12 - return ` 13 - <div tw="bg-zinc-900 flex flex-col w-full h-full items-center justify-center"> 14 - 15 - <div tw="flex absolute bottom-0 left-0 right-0 top-0 bg-sky-400"> 16 - <img src="https://image.tmdb.org/t/p/w780${data.backdrop_path}" alt="" class="flex h-full w-full rounded-xl opacity-50" /> 17 - 18 - <div tw="flex absolute h-full w-full bg-black/80"> 19 - </div> 20 - </div> 21 - 22 - <div tw="flex flex-row w-full py-8 px-16 items-center justify-start"> 23 - <div tw="flex h-auto aspect-[3/2] w-72"> 24 - <img src="https://image.tmdb.org/t/p/w500${data.poster_path}" alt="" 25 - class="flex h-full w-full rounded-xl border-2 border-zinc-800" style="border-radius: 16px; border-width: 1px; border-color: #3f3f46;" /> 26 - </div> 27 - 28 - <h2 tw="flex flex-col text-7xl font-bold text-zinc-100 text-left px-12 max-w-3xl"> 29 - <span tw="flex tracking-tight">${data.title ?? data.name}</span> 30 - </h2> 31 - </div> 32 - 33 - <div tw="flex text-4xl text-sky-400 items-end justify-end w-full px-8"> 34 - <div tw="flex"> 35 - rate on skywatched.app 36 - </div> 37 - </div> 38 - </div>`; 39 - }; 40 - 41 - const host = import.meta.env.DEV ? 'http://localhost:5173' : 'https://skywatched.app'; 42 - const fontPath = `fonts/inter-latin-ext-400-normal.woff`; 43 - const fontFile = await fetch(`${host}/${fontPath}`); 44 - const fontData: ArrayBuffer = await fontFile.arrayBuffer(); 45 - 46 - export const GET: RequestHandler = async ({ params }) => { 47 - const id = parseInt(params.id?.split('-')[0] ?? ''); 48 - const kind = params.kind; 49 - 50 - if (kind !== 'movie' && kind !== 'tv') { 51 - return error(404, 'Not found'); 52 - } 53 - 54 - if (!id) { 55 - return error(404, 'Not found'); 56 - } 57 - const result = await getDetails(id, kind); 58 - 59 - return new ImageResponse( 60 - template(result), 61 - { 62 - fonts: [ 63 - { 64 - name: 'Inter Latin', 65 - data: fontData, 66 - weight: 400 67 - } 68 - ] 69 - }, 70 - {} 71 - ); 72 - };
-1
src/routes/review/[uri]/og.png/+server.ts
··· 4 4 import { error, type RequestHandler } from '@sveltejs/kit'; 5 5 6 6 const template = (data: MainRecord) => { 7 - 8 7 const backdrop = data.record.metadata?.backdrop_path; 9 8 const poster = data.record.metadata?.poster_path; 10 9