[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 #67 from flo-bit/likes

likes, v0.1

author
Florian
committer
GitHub
date (Dec 22, 2024, 1:44 AM +0100) commit 890bd6c0 parent 6910be70
+213 -20
+1 -1
.github/workflows/fly-deploy.yml
··· 9 9 deploy: 10 10 name: Deploy app 11 11 runs-on: ubuntu-latest 12 - concurrency: deploy-group # optional: ensure only one action runs at a time 12 + concurrency: deploy-group # optional: ensure only one action runs at a time 13 13 steps: 14 14 - uses: actions/checkout@v4 15 15 - uses: superfly/flyctl-actions/setup-flyctl@master
+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 + ]
+26
scripts/backfill-authors.ts
··· 1 + import fs from 'fs'; 2 + 3 + async function main() { 4 + // const backendUrl = 'http://127.0.0.1:3001'; 5 + const backendUrl = 'https://skywatched-jetstream.fly.dev'; 6 + 7 + // get all authors from "author-dids.json" 8 + const authorDids = JSON.parse(fs.readFileSync('author-dids.json', 'utf8')); 9 + 10 + console.log(authorDids); 11 + 12 + for (const authorDid of authorDids) { 13 + try { 14 + const response = await fetch(`${backendUrl}/api/refresh-user?did=${authorDid}`); 15 + const data = await response.json(); 16 + console.log(data); 17 + } catch (error) { 18 + console.error(`Error refreshing user ${authorDid}:`, error); 19 + } 20 + 21 + // wait 1 second 22 + await new Promise((resolve) => setTimeout(resolve, 1000)); 23 + } 24 + } 25 + 26 + main();
+15
scripts/get-all-authors.ts
··· 1 + import fs from 'fs'; 2 + 3 + async function main() { 4 + const backendUrl = 'https://skywatched-jetstream.fly.dev'; 5 + 6 + const response = await fetch(`${backendUrl}/api/author-dids`); 7 + const authorDids = await response.json(); 8 + 9 + // save to file 10 + fs.writeFileSync('author-dids.json', JSON.stringify(authorDids, null, 2)); 11 + 12 + console.log(authorDids); 13 + } 14 + 15 + main();
+5 -5
src/app.css
··· 8 8 9 9 /* width */ 10 10 ::-webkit-scrollbar { 11 - width: 5px; 11 + width: 5px; 12 12 height: 5px; 13 13 } 14 14 15 15 /* Track */ 16 16 ::-webkit-scrollbar-track { 17 - @apply bg-transparent; 17 + @apply bg-transparent; 18 18 } 19 19 20 20 /* Handle */ 21 21 ::-webkit-scrollbar-thumb { 22 - @apply bg-base-600 hover:bg-accent-500 transition-colors duration-150; 23 - border-radius: 5px; 22 + @apply bg-base-600 transition-colors duration-150 hover:bg-accent-500; 23 + border-radius: 5px; 24 24 } 25 25 26 26 .poster { ··· 29 29 30 30 .title { 31 31 view-transition-name: var(--name); 32 - } 32 + }
+1 -1
src/lib/Components/Input.svelte
··· 4 4 5 5 <input 6 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 sm:text-sm/6 dark:bg-base-950 dark:text-base-100 dark:ring-base-700 dark:placeholder:text-base-600" 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 8 {...rest} 9 9 />
+1 -1
src/lib/Components/ItemCard.svelte
··· 27 27 <img 28 28 src="https://image.tmdb.org/t/p/w342{item.poster_path}" 29 29 alt="movie poster for {item.title ?? item.name}" 30 - class="size-full object-cover object-center lg:size-full poster" 30 + class="poster size-full object-cover object-center lg:size-full" 31 31 loading="lazy" 32 32 /> 33 33 {/if}
+4 -2
src/lib/Components/LoginModal.svelte
··· 17 17 ></div> 18 18 19 19 <div class="pointer-events-none fixed inset-0 z-10 w-screen overflow-y-auto"> 20 - <div class="flex min-h-[100dvh] items-end justify-center p-4 text-center sm:items-center sm:p-0"> 20 + <div 21 + class="flex min-h-[100dvh] items-end justify-center p-4 text-center sm:items-center sm:p-0" 22 + > 21 23 <div 22 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" 23 25 > ··· 39 41 type="text" 40 42 name="handle" 41 43 id="handle" 42 - class="block w-full rounded-md border-0 py-1.5 text-base-900 shadow-sm ring-1 ring-inset ring-base-300 placeholder:text-base-400 focus:ring-2 focus:ring-inset focus:ring-accent-600 sm:text-sm/6 dark:bg-base-900 dark:text-base-100 dark:ring-base-700 dark:placeholder:text-base-600" 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" 43 45 placeholder="yourname.bsky.social" 44 46 /> 45 47 </div>
+89 -4
src/lib/Components/ReviewCard.svelte
··· 1 1 <script lang="ts"> 2 2 import type { MainRecord } from '$lib/db'; 3 + import { toast } from 'svelte-sonner'; 3 4 import Rating from './Rating.svelte'; 4 5 import RelativeTime from './relative-time/RelativeTime.svelte'; 5 6 6 7 let { data, showMovieDetails = true }: { data: MainRecord; showMovieDetails?: boolean } = 7 8 $props(); 9 + 10 + let isLiked = $state(false); 8 11 </script> 9 12 10 - <div class="relative w-full max-w-2xl p-6 backdrop-blur-sm"> 11 - <div class="flex items-center gap-4 max-w-full overflow-hidden"> 13 + <div class="relative w-full max-w-2xl p-4 backdrop-blur-sm"> 14 + <div class="flex max-w-full items-center gap-4 overflow-hidden"> 12 15 {#if showMovieDetails} 13 16 <a 14 17 href={data.record.item.ref === 'tmdb:m' ··· 22 25 alt="movie poster for {data.record.metadata.title}" 23 26 class="poster size-full object-cover object-center lg:size-full" 24 27 style:--name={`poster-${data.record.item.value}`} 25 - /> 28 + /> 26 29 {/if} 27 30 <span class="sr-only">View {data.record.metadata?.title}</span> 28 31 </a> 29 32 {/if} 30 33 31 34 <div class="flex flex-col gap-3"> 32 - <a href={`/user/${data.author.handle}`} class="flex flex-row items-center gap-4 font-medium overflow-hidden"> 35 + <a 36 + href={`/user/${data.author.handle}`} 37 + class="flex flex-row items-center gap-4 overflow-hidden font-medium" 38 + > 33 39 <div class="flex items-center gap-2"> 34 40 {#if data.author.avatar} 35 41 <img src={data.author.avatar} alt="user avatar" class="size-5 rounded-full" /> ··· 67 73 {@html data.record.note?.value?.replace('\n', '<br /><br />')} 68 74 </div> 69 75 {/if} 76 + 77 + <div class="mt-6 flex justify-between gap-2 text-base-500 dark:text-base-400"> 78 + <!-- <button class="group inline-flex items-center gap-2 text-sm"> 79 + <svg 80 + xmlns="http://www.w3.org/2000/svg" 81 + fill="none" 82 + viewBox="0 0 24 24" 83 + stroke-width="1.5" 84 + stroke="currentColor" 85 + 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" 86 + > 87 + <path 88 + stroke-linecap="round" 89 + stroke-linejoin="round" 90 + 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" 91 + /> 92 + </svg> 93 + {#if data.post.replyCount} 94 + {numberToHumanReadable(data.post.replyCount)} 95 + {/if} 96 + </button> --> 97 + 98 + <!-- this is kind of how the bookmark button should look like too 99 + (but it should work without login too and just use the local db) --> 100 + <button 101 + class="group inline-flex items-center gap-2 text-sm" 102 + onclick={async () => { 103 + if (isLiked) return; 104 + 105 + isLiked = true; 106 + 107 + try { 108 + const response = await fetch('/api/like', { 109 + method: 'POST', 110 + body: JSON.stringify({ cid: data.cid, uri: data.uri }) 111 + }); 112 + if (response.ok) { 113 + toast.success('Liked'); 114 + } else { 115 + toast.error('must be logged in to like'); 116 + isLiked = false; 117 + } 118 + } catch (e) { 119 + console.error(e); 120 + toast.error('must be logged in to like'); 121 + isLiked = false; 122 + } 123 + }} 124 + > 125 + {#if isLiked} 126 + <svg 127 + xmlns="http://www.w3.org/2000/svg" 128 + viewBox="0 0 24 24" 129 + fill="currentColor" 130 + 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" 131 + > 132 + <path 133 + 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" 134 + /> 135 + </svg> 136 + {:else} 137 + <svg 138 + xmlns="http://www.w3.org/2000/svg" 139 + fill="none" 140 + viewBox="0 0 24 24" 141 + stroke-width="1.5" 142 + stroke="currentColor" 143 + 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" 144 + > 145 + <path 146 + stroke-linecap="round" 147 + stroke-linejoin="round" 148 + 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" 149 + /> 150 + </svg> 151 + {/if} 152 + {(data.record.likes ?? 0) + (isLiked ? 1 : 0)} 153 + </button> 154 + </div> 70 155 71 156 <!-- <a href={`/review/${encodeURIComponent(data.uri)}`}> 72 157 <span class="absolute inset-0 z-10"></span>
+5
src/lib/db.ts
··· 41 41 }; 42 42 crosspost?: { 43 43 uri: string; 44 + likes?: number; 45 + reposts?: number; 46 + replies?: number; 44 47 }; 48 + 49 + likes?: number; 45 50 }; 46 51 }; 47 52
+8 -5
src/routes/[kind]/[id]/+page.svelte
··· 68 68 /> 69 69 <div class="fixed inset-0 h-full w-full bg-black/50"></div> 70 70 71 - <Container class="relative z-10 pt-4 pb-8"> 71 + <Container class="relative z-10 pb-8 pt-4"> 72 72 <div class="flex gap-4 px-4 pt-8"> 73 73 <img 74 74 src="https://image.tmdb.org/t/p/w500{data.result.poster_path}" ··· 77 77 style:--name={`poster-${data.result.id}`} 78 78 /> 79 79 <div class="flex flex-col gap-4"> 80 - <div class="title max-w-xl text-2xl font-semibold text-white sm:text-4xl" style:--name={`title-${data.result.id}`}> 80 + <div 81 + class="title max-w-xl text-2xl font-semibold text-white sm:text-4xl" 82 + style:--name={`title-${data.result.id}`} 83 + > 81 84 {data.result.title ?? data.result.name} 82 85 </div> 83 86 ··· 146 149 147 150 <div class={cn('flex gap-x-6 overflow-x-auto')}> 148 151 {#each data.cast as castMember} 149 - <a href={`/cast/${castMember.id}`} class="flex flex-col gap-1 items-center"> 152 + <a href={`/cast/${castMember.id}`} class="flex flex-col items-center gap-1"> 150 153 <Avatar 151 154 src={castMember.profile_path 152 155 ? 'https://image.tmdb.org/t/p/w500' + castMember.profile_path 153 156 : undefined} 154 157 size="size-32" 155 158 /> 156 - <div class="text-xs font-medium text-center">{castMember.name}</div> 157 - <div class="text-xs text-base-400 text-center">{castMember.character}</div> 159 + <div class="text-center text-xs font-medium">{castMember.name}</div> 160 + <div class="text-center text-xs text-base-400">{castMember.character}</div> 158 161 </a> 159 162 {/each} 160 163 </div>
+40
src/routes/api/like/+server.ts
··· 1 + import { error, json, type RequestHandler } from '@sveltejs/kit'; 2 + import { AtpBaseClient } from '@atproto/api'; 3 + import { TID } from '@atproto/common'; 4 + 5 + export const POST: RequestHandler = async ({ request, locals }) => { 6 + const user = locals.user; 7 + const agent = locals.agent; 8 + if (!user || !agent || agent instanceof AtpBaseClient) { 9 + return error(401, 'Unauthorized API call'); 10 + } 11 + const body = await request.json(); 12 + 13 + const cid = body.cid; 14 + const uri = body.uri; 15 + 16 + const did = user.did; 17 + 18 + const rkey = TID.nextStr(); 19 + 20 + const record: { 21 + repo: string; 22 + collection: string; 23 + rkey: string; 24 + record: { 25 + subject: { cid: string; uri: string }; 26 + createdAt: string; 27 + }; 28 + } = { 29 + repo: did, 30 + collection: 'community.lexicon.interaction.like', 31 + rkey, 32 + record: { 33 + subject: { cid, uri }, 34 + createdAt: new Date().toISOString() 35 + } 36 + }; 37 + await agent.com.atproto.repo.putRecord(record); 38 + 39 + return json({ status: 'liked' }); 40 + };
+4 -1
src/routes/cast/[id]/+page.svelte
··· 49 49 {data.personDetails.biography} 50 50 </div> 51 51 {#if !showFullBiography} 52 - <button class="text-accent-400 font-semibold mt-1" onclick={() => (showFullBiography = true)}> 52 + <button 53 + class="mt-1 font-semibold text-accent-400" 54 + onclick={() => (showFullBiography = true)} 55 + > 53 56 show more 54 57 </button> 55 58 {/if}