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

allow editing

Florian (Jan 22, 2025, 7:52 PM +0100) 768d6744 7d00abbf

+220 -79
+8 -2
src/lib/state.svelte.ts
··· 40 40 posterPath: string | undefined; 41 41 currentRating: number | undefined; 42 42 currentReview: string | undefined; 43 + 44 + editUri?: string; 43 45 }; 44 46 45 47 export const rateMovieModal: { ··· 60 62 name: undefined, 61 63 posterPath: undefined, 62 64 currentRating: undefined, 63 - currentReview: undefined 65 + currentReview: undefined, 66 + 67 + editUri: undefined 64 68 }, 65 69 66 70 show: (item: RateMovieModalItem) => { ··· 80 84 name: undefined, 81 85 posterPath: undefined, 82 86 currentRating: undefined, 83 - currentReview: undefined 87 + currentReview: undefined, 88 + 89 + editUri: undefined 84 90 }; 85 91 rateMovieModal.showModal = true; 86 92 }
+56 -30
src/lib/Components/OptionButton.svelte
··· 1 1 <script lang="ts"> 2 2 import { type MainRecord } from '$lib/db'; 3 - import { user } from '$lib/state.svelte'; 3 + import { rateMovieModal, user } from '$lib/state.svelte'; 4 4 import { createDropdownMenu, melt } from '@melt-ui/svelte'; 5 + import { toast } from 'svelte-sonner'; 5 6 import { fly } from 'svelte/transition'; 6 7 7 8 const { 8 - elements: { trigger, menu, item, separator, arrow }, 9 - builders: { createSubmenu, createMenuRadioGroup, createCheckboxItem }, 9 + elements: { trigger, menu }, 10 10 states: { open } 11 11 } = createDropdownMenu({ 12 12 forceVisible: true, ··· 37 37 38 38 {#if $open} 39 39 <div 40 - class="bg-base-900 border-base-800 divide-base-800 z-20 flex flex-col items-start divide-y overflow-hidden rounded-xl border" 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 41 use:melt={$menu} 42 42 transition:fly={{ duration: 150, y: -10 }} 43 43 > ··· 49 49 method: 'DELETE' 50 50 }); 51 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 + } 52 62 }} 53 63 ><svg 54 64 xmlns="http://www.w3.org/2000/svg" ··· 70 80 <button 71 81 class="item" 72 82 on:click={() => { 73 - alert('Check for Updates...'); 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 + }); 74 96 }} 75 97 ><svg 76 98 xmlns="http://www.w3.org/2000/svg" ··· 90 112 edit review</button 91 113 > 92 114 {:else} 93 - <button 115 + <!-- <button 94 116 class="item" 95 117 on:click={() => { 96 118 alert('Check for Updates...'); ··· 112 134 </svg> 113 135 114 136 report review</button 115 - > 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> 116 158 {/if} 117 159 118 160 <button 119 161 class="item" 120 162 on:click={() => { 121 - alert('Check for Updates...'); 122 - }} 123 - > 124 - <svg 125 - xmlns="http://www.w3.org/2000/svg" 126 - fill="none" 127 - viewBox="0 0 24 24" 128 - stroke-width="1" 129 - stroke="currentColor" 130 - class="size-4" 131 - > 132 - <path 133 - stroke-linecap="round" 134 - stroke-linejoin="round" 135 - 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" 136 - /> 137 - </svg> 138 - go to crosspost 139 - </button> 163 + $open = false; 140 164 141 - <button 142 - class="item" 143 - on:click={() => { 144 - alert('Check for Updates...'); 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'); 145 171 }} 146 172 > 147 173 <svg
+82 -45
src/lib/Components/RateMovieModal.svelte
··· 13 13 }); 14 14 15 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 + } 16 79 </script> 17 80 18 81 {#if rateMovieModal.showModal} 19 82 <div class="relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true"> 20 83 <div 21 - class="fixed inset-0 bg-base-950/90 backdrop-blur-sm transition-opacity" 84 + class="bg-base-950/90 fixed inset-0 backdrop-blur-sm transition-opacity" 22 85 onclick={() => (rateMovieModal.showModal = false)} 23 86 aria-hidden="true" 24 87 ></div> 25 88 <div class="pointer-events-none fixed inset-0 z-50 h-[100dvh] w-screen overflow-y-auto"> 26 89 <div class="flex h-[100dvh] items-end justify-center p-4 text-center sm:items-center sm:p-0"> 27 90 <div 28 - 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-sm sm:p-6" 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" 29 92 > 30 93 <button 31 - class="absolute right-2 top-2 rounded-full bg-base-800/50 p-1 hover:bg-base-800/80" 94 + class="bg-base-800/50 hover:bg-base-800/80 absolute right-2 top-2 rounded-full p-1" 32 95 onclick={() => (rateMovieModal.showModal = false)} 33 96 > 34 97 <svg ··· 46 109 </button> 47 110 48 111 <div> 49 - <h3 class="text-md mb-4 font-semibold text-base-50" id="modal-title"> 50 - Rate and review 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} 51 118 </h3> 52 119 53 120 {#if rateMovieModal.selectedItem.name} 54 121 <div class="relative flex items-center gap-4"> 55 122 <div 56 - class="relative z-20 aspect-[2/3] h-32 w-auto shrink-0 overflow-hidden rounded-md border border-base-800 bg-base-900/50" 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" 57 124 > 58 125 {#if rateMovieModal.selectedItem.posterPath} 59 126 <img ··· 64 131 {/if} 65 132 </div> 66 133 <h3 67 - class="mb-4 flex flex-col gap-2 text-xl font-semibold text-base-50" 134 + class="text-base-50 mb-4 flex flex-col gap-2 text-xl font-semibold" 68 135 id="modal-title" 69 136 > 70 137 {rateMovieModal.selectedItem.name} ··· 85 152 {/if} 86 153 87 154 <div class="mt-4"> 88 - <label for="comment" class="block text-xs font-medium text-base-50">review</label> 155 + <label for="comment" class="text-base-50 block text-xs font-medium">review</label> 89 156 <div class="mt-2"> 90 157 <textarea 91 158 rows="4" ··· 93 160 id="comment" 94 161 bind:value={review} 95 162 placeholder="write a review" 96 - class="outline-nonse block w-full rounded-lg border border-base-800 bg-base-950 px-3 py-1.5 text-base text-base-50 -outline-offset-1 placeholder:text-base-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-accent-400 sm:text-sm/6" 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" 97 164 ></textarea> 98 165 </div> 99 166 </div> ··· 101 168 <div class="mt-5 sm:mt-6"> 102 169 <button 103 170 onclick={async () => { 104 - const response = await fetch(`/api/rate`, { 105 - method: 'POST', 106 - body: JSON.stringify({ 107 - rating, 108 - review, 109 - kind: rateMovieModal.selectedItem.kind, 110 - id: rateMovieModal.selectedItem.movieId ?? rateMovieModal.selectedItem.showId 111 - }) 112 - }); 113 - 114 - if (!response.ok) { 115 - toast.error('Failed to save rating'); 116 - 117 - return; 171 + if (rateMovieModal.selectedItem.editUri) { 172 + saveEdit(); 173 + } else { 174 + saveNew(); 118 175 } 119 - rateMovieModal.showModal = false; 120 - 121 - const data = await response.json(); 122 - 123 - if (settings.crosspostEnabled && review.length > 0) { 124 - crosspostModal.show( 125 - data.uri, 126 - review, 127 - rating, 128 - rateMovieModal.selectedItem.name ?? '' 129 - ); 130 - } 131 - 132 - watchedItems.addRated({ 133 - movieId: rateMovieModal.selectedItem.movieId, 134 - showId: rateMovieModal.selectedItem.showId, 135 - rating 136 - }); 137 - 138 - toast.success('Rating saved'); 139 176 }} 140 177 type="button" 141 178 disabled={sending || !rateMovieModal.selectedItem.name} 142 - class="inline-flex w-full justify-center rounded-md border border-accent-900 bg-accent-950/80 px-3 py-2 text-sm font-semibold text-accent-300 shadow-sm hover:bg-accent-950 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600 disabled:cursor-not-allowed disabled:opacity-50" 143 - >{sending ? 'Sending...' : 'Review'}</button 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 144 181 > 145 182 </div> 146 183 </div>
+74 -2
src/routes/api/review/+server.ts
··· 2 2 import { AtpBaseClient } from '@atproto/api'; 3 3 import { REL_COLLECTION } from '$lib'; 4 4 5 - export const DELETE: RequestHandler = async ({ request, locals, url }) => { 5 + export const DELETE: RequestHandler = async ({ locals, url }) => { 6 6 const user = locals.user; 7 7 const agent = locals.agent; 8 8 if (!user || !agent || agent instanceof AtpBaseClient) { ··· 21 21 return error(401, 'Unauthorized API call'); 22 22 } 23 23 24 - await agent.com.atproto.repo.deleteRecord({ repo: did, collection: REL_COLLECTION, rkey }); 24 + if (collection !== REL_COLLECTION) { 25 + return error(400, 'Invalid collection'); 26 + } 27 + 28 + await agent.com.atproto.repo.deleteRecord({ repo: did, collection: collection, rkey }); 25 29 26 30 return json({ status: 'deleted', uri: `at://${did}/${REL_COLLECTION}/${rkey}` }); 31 + }; 32 + 33 + export const PUT: RequestHandler = async ({ locals, request }) => { 34 + const user = locals.user; 35 + const agent = locals.agent; 36 + if (!user || !agent || agent instanceof AtpBaseClient) { 37 + return error(401, 'Unauthorized API call'); 38 + } 39 + 40 + const body = await request.json(); 41 + const uri = body.uri; 42 + 43 + const [did, collection, rkey] = uri.replace('at://', '').split('/'); 44 + 45 + if (did !== user.did) { 46 + return error(401, 'Unauthorized API call'); 47 + } 48 + 49 + if (collection !== REL_COLLECTION) { 50 + return error(400, 'Invalid collection'); 51 + } 52 + 53 + const rating = body.rating; 54 + const review = body.review; 55 + const kind = body.kind; 56 + const id = body.id; 57 + 58 + const record: { 59 + repo: string; 60 + collection: string; 61 + rkey: string; 62 + record: { 63 + item: { 64 + ref: string; 65 + value: string; 66 + }; 67 + rating: { value: number; createdAt: string }; 68 + note?: { 69 + value: string; 70 + createdAt: string; 71 + updatedAt: string; 72 + }; 73 + from?: string; 74 + }; 75 + } = { 76 + repo: did, 77 + collection: REL_COLLECTION, 78 + rkey, 79 + record: { 80 + item: { 81 + ref: `tmdb:${kind === 'movie' ? 'm' : 's'}`, 82 + value: id.toString() 83 + }, 84 + rating: { value: rating * 2, createdAt: new Date().toISOString() }, 85 + from: 'skywatched' 86 + } 87 + }; 88 + if (review) { 89 + record.record.note = { 90 + value: review, 91 + createdAt: new Date().toISOString(), 92 + updatedAt: new Date().toISOString() 93 + }; 94 + } 95 + 96 + await agent.com.atproto.repo.putRecord(record); 97 + 98 + return json({ status: 'ok' }); 27 99 };