[READ-ONLY] Mirror of https://github.com/flo-bit/skywatched. review movies and tv shows, based on at proto skywatched.app
0

Configure Feed

Select the types of activity you want to include in your feed.

add onboarding, add searching in review modal, use selected streaming region

Florian (Dec 17, 2024, 8:31 AM +0100) 657d95e7 e14cadba

+1548 -319
+128 -1
package-lock.json
··· 25 25 "tailwind-merge": "^2.5.4" 26 26 }, 27 27 "devDependencies": { 28 + "@melt-ui/pp": "^0.3.2", 29 + "@melt-ui/svelte": "^0.86.2", 28 30 "@sveltejs/adapter-auto": "^3.0.0", 29 31 "@sveltejs/kit": "^2.0.0", 30 32 "@sveltejs/vite-plugin-svelte": "^4.0.0", ··· 1359 1361 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" 1360 1362 } 1361 1363 }, 1364 + "node_modules/@floating-ui/core": { 1365 + "version": "1.6.8", 1366 + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", 1367 + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", 1368 + "dev": true, 1369 + "dependencies": { 1370 + "@floating-ui/utils": "^0.2.8" 1371 + } 1372 + }, 1373 + "node_modules/@floating-ui/dom": { 1374 + "version": "1.6.12", 1375 + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", 1376 + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", 1377 + "dev": true, 1378 + "dependencies": { 1379 + "@floating-ui/core": "^1.6.0", 1380 + "@floating-ui/utils": "^0.2.8" 1381 + } 1382 + }, 1383 + "node_modules/@floating-ui/utils": { 1384 + "version": "0.2.8", 1385 + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", 1386 + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", 1387 + "dev": true 1388 + }, 1362 1389 "node_modules/@humanfs/core": { 1363 1390 "version": "0.19.1", 1364 1391 "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", ··· 1418 1445 "funding": { 1419 1446 "type": "github", 1420 1447 "url": "https://github.com/sponsors/nzakas" 1448 + } 1449 + }, 1450 + "node_modules/@internationalized/date": { 1451 + "version": "3.6.0", 1452 + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.6.0.tgz", 1453 + "integrity": "sha512-+z6ti+CcJnRlLHok/emGEsWQhe7kfSmEW+/6qCzvKY67YPh7YOBfvc7+/+NXq+zJlbArg30tYpqLjNgcAYv2YQ==", 1454 + "dev": true, 1455 + "dependencies": { 1456 + "@swc/helpers": "^0.5.0" 1421 1457 } 1422 1458 }, 1423 1459 "node_modules/@ipld/dag-cbor": { ··· 1668 1704 "encoding": { 1669 1705 "optional": true 1670 1706 } 1707 + } 1708 + }, 1709 + "node_modules/@melt-ui/pp": { 1710 + "version": "0.3.2", 1711 + "resolved": "https://registry.npmjs.org/@melt-ui/pp/-/pp-0.3.2.tgz", 1712 + "integrity": "sha512-xKkPvaIAFinklLXcQOpwZ8YSpqAFxykjWf8Y/fSJQwsixV/0rcFs07hJ49hJjPy5vItvw5Qa0uOjzFUbXzBypQ==", 1713 + "dev": true, 1714 + "dependencies": { 1715 + "estree-walker": "^3.0.3", 1716 + "magic-string": "^0.30.5" 1717 + }, 1718 + "peerDependencies": { 1719 + "@melt-ui/svelte": ">= 0.29.0", 1720 + "svelte": "^3.55.0 || ^4.0.0 || ^5.0.0-next.1" 1721 + } 1722 + }, 1723 + "node_modules/@melt-ui/pp/node_modules/estree-walker": { 1724 + "version": "3.0.3", 1725 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", 1726 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", 1727 + "dev": true, 1728 + "dependencies": { 1729 + "@types/estree": "^1.0.0" 1730 + } 1731 + }, 1732 + "node_modules/@melt-ui/svelte": { 1733 + "version": "0.86.2", 1734 + "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.86.2.tgz", 1735 + "integrity": "sha512-wRVN603oIt1aXvx2QRmKqVDJgTScSvr/WJLLokkD8c4QzHgn6pfpPtUKmhV6Dvkk+OY89OG/1Irkd6ouA50Ztw==", 1736 + "dev": true, 1737 + "dependencies": { 1738 + "@floating-ui/core": "^1.3.1", 1739 + "@floating-ui/dom": "^1.4.5", 1740 + "@internationalized/date": "^3.5.0", 1741 + "dequal": "^2.0.3", 1742 + "focus-trap": "^7.5.2", 1743 + "nanoid": "^5.0.4" 1744 + }, 1745 + "peerDependencies": { 1746 + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.118" 1747 + } 1748 + }, 1749 + "node_modules/@melt-ui/svelte/node_modules/nanoid": { 1750 + "version": "5.0.9", 1751 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz", 1752 + "integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==", 1753 + "dev": true, 1754 + "funding": [ 1755 + { 1756 + "type": "github", 1757 + "url": "https://github.com/sponsors/ai" 1758 + } 1759 + ], 1760 + "bin": { 1761 + "nanoid": "bin/nanoid.js" 1762 + }, 1763 + "engines": { 1764 + "node": "^18 || >=20" 1671 1765 } 1672 1766 }, 1673 1767 "node_modules/@napi-rs/wasm-runtime": { ··· 2367 2461 "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", 2368 2462 "svelte": "^5.0.0-next.96 || ^5.0.0", 2369 2463 "vite": "^5.0.0" 2464 + } 2465 + }, 2466 + "node_modules/@swc/helpers": { 2467 + "version": "0.5.15", 2468 + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", 2469 + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", 2470 + "dev": true, 2471 + "dependencies": { 2472 + "tslib": "^2.8.0" 2370 2473 } 2371 2474 }, 2372 2475 "node_modules/@tailwindcss/aspect-ratio": { ··· 3462 3565 "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", 3463 3566 "engines": { 3464 3567 "node": ">= 0.8" 3568 + } 3569 + }, 3570 + "node_modules/dequal": { 3571 + "version": "2.0.3", 3572 + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", 3573 + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", 3574 + "dev": true, 3575 + "engines": { 3576 + "node": ">=6" 3465 3577 } 3466 3578 }, 3467 3579 "node_modules/destroy": { ··· 4711 4823 "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", 4712 4824 "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", 4713 4825 "dev": true 4826 + }, 4827 + "node_modules/focus-trap": { 4828 + "version": "7.6.2", 4829 + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.2.tgz", 4830 + "integrity": "sha512-9FhUxK1hVju2+AiQIDJ5Dd//9R2n2RAfJ0qfhF4IHGHgcoEUTMpbTeG/zbEuwaiYXfuAH6XE0/aCyxDdRM+W5w==", 4831 + "dev": true, 4832 + "dependencies": { 4833 + "tabbable": "^6.2.0" 4834 + } 4714 4835 }, 4715 4836 "node_modules/foreground-child": { 4716 4837 "version": "3.3.0", ··· 7196 7317 "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" 7197 7318 } 7198 7319 }, 7320 + "node_modules/tabbable": { 7321 + "version": "6.2.0", 7322 + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", 7323 + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", 7324 + "dev": true 7325 + }, 7199 7326 "node_modules/tailwind-merge": { 7200 7327 "version": "2.5.5", 7201 7328 "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.5.tgz", ··· 7499 7626 "version": "2.8.1", 7500 7627 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 7501 7628 "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 7502 - "optional": true 7629 + "devOptional": true 7503 7630 }, 7504 7631 "node_modules/type-check": { 7505 7632 "version": "0.4.0",
+2
package.json
··· 15 15 "db:studio": "drizzle-kit studio" 16 16 }, 17 17 "devDependencies": { 18 + "@melt-ui/pp": "^0.3.2", 19 + "@melt-ui/svelte": "^0.86.2", 18 20 "@sveltejs/adapter-auto": "^3.0.0", 19 21 "@sveltejs/kit": "^2.0.0", 20 22 "@sveltejs/vite-plugin-svelte": "^4.0.0",
+3 -5
svelte.config.js
··· 1 + import { preprocessMeltUI, sequence } from '@melt-ui/pp'; 1 2 import adapter from '@sveltejs/adapter-vercel'; 2 3 import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 - 4 - /** @type {import('@sveltejs/kit').Config} */ 4 + /** @type {import('@sveltejs/kit').Config}*/ 5 5 const config = { 6 6 // Consult https://svelte.dev/docs/kit/integrations 7 7 // for more information about preprocessors 8 - preprocess: vitePreprocess(), 9 - 8 + preprocess: sequence([vitePreprocess(), preprocessMeltUI()]), 10 9 kit: { 11 10 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 11 // If your environment is not supported, or you settled on a specific environment, switch out the adapter. ··· 14 13 adapter: adapter() 15 14 } 16 15 }; 17 - 18 16 export default config;
+38 -8
src/lib/state.svelte.ts
··· 32 32 } 33 33 }); 34 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 + 35 45 export const rateMovieModal: { 36 46 showModal: boolean; 37 47 38 - selectedItem: { 39 - movieId: number | undefined; 40 - showId: number | undefined; 41 - kind: string | undefined; 42 - name: string | undefined; 43 - currentRating: number | undefined; 44 - currentReview: string | undefined; 45 - }; 48 + selectedItem: RateMovieModalItem; 49 + 50 + show: (item: RateMovieModalItem) => void; 51 + hide: () => void; 52 + showEmpty: () => void; 46 53 } = $state({ 47 54 showModal: false, 48 55 ··· 51 58 showId: undefined, 52 59 kind: undefined, 53 60 name: undefined, 61 + posterPath: undefined, 54 62 currentRating: undefined, 55 63 currentReview: undefined 64 + }, 65 + 66 + show: (item: RateMovieModalItem) => { 67 + rateMovieModal.selectedItem = item; 68 + rateMovieModal.showModal = true; 69 + }, 70 + 71 + hide: () => { 72 + rateMovieModal.showModal = false; 73 + }, 74 + 75 + showEmpty: () => { 76 + rateMovieModal.selectedItem = { 77 + movieId: undefined, 78 + showId: undefined, 79 + kind: undefined, 80 + name: undefined, 81 + posterPath: undefined, 82 + currentRating: undefined, 83 + currentReview: undefined 84 + }; 85 + rateMovieModal.showModal = true; 56 86 } 57 87 }); 58 88
+24 -1
src/routes/+layout.server.ts
··· 1 + import { Agent } from '@atproto/api'; 1 2 import type { LayoutServerLoad } from './$types'; 2 3 3 4 export const load: LayoutServerLoad = async (event) => { 4 - return { user: event.locals.user }; 5 + const agent = event.locals.agent; 6 + 7 + if (!agent || !(agent instanceof Agent)) return { user: event.locals.user, settings: undefined }; 8 + 9 + try { 10 + const settings_record = await agent.com.atproto.repo.getRecord({ 11 + repo: event.locals.user.did, 12 + collection: 'app.skywatched.settings', 13 + rkey: 'self' 14 + }); 15 + 16 + const settings = settings_record.data.value as unknown as { 17 + streaming_region: { 18 + code: string; 19 + name: string; 20 + }; 21 + crosspost_enabled: boolean; 22 + }; 23 + return { user: event.locals.user, settings }; 24 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 + } catch (_e) { 26 + return { user: event.locals.user, settings: undefined }; 27 + } 5 28 };
+4 -4
src/routes/+layout.svelte
··· 39 39 new Date(lastUpdate).getTime() + 10 * 60 * 1000 > Date.now() 40 40 ) { 41 41 watchedItems.ratedMovies = new Map( 42 - JSON.parse(cachedRatedItems).movies.map((movie) => [movie.id, movie]) 42 + JSON.parse(cachedRatedItems).movies.map((movie: any) => [movie.id, movie]) 43 43 ); 44 44 watchedItems.ratedShows = new Map( 45 - JSON.parse(cachedRatedItems).shows.map((show) => [show.id, show]) 45 + JSON.parse(cachedRatedItems).shows.map((show: any) => [show.id, show]) 46 46 ); 47 47 48 48 console.log(watchedItems.ratedMovies); ··· 62 62 const response = await fetch(`/api/getAllRated?did=${data.user.did}`); 63 63 const items = await response.json(); 64 64 65 - watchedItems.ratedMovies = new Map(items.movies.map((movie) => [movie.id, movie])); 66 - watchedItems.ratedShows = new Map(items.shows.map((show) => [show.id, show])); 65 + watchedItems.ratedMovies = new Map(items.movies.map((movie: any) => [movie.id, movie])); 66 + watchedItems.ratedShows = new Map(items.shows.map((show: any) => [show.id, show])); 67 67 localStorage.setItem(`ratedItems-${data.user.did}`, JSON.stringify(items)); 68 68 localStorage.setItem(`ratedItems-${data.user.did}-lastUpdate`, new Date().toISOString()); 69 69
+29 -2
src/routes/+page.svelte
··· 1 + <script> 2 + import { rateMovieModal, showLoginModal } from '$lib/state.svelte'; 3 + 4 + const { data } = $props(); 5 + </script> 6 + 1 7 <svelte:head> 2 8 <title>skywatched</title> 3 9 </svelte:head> ··· 22 28 <h1 class="text-balance text-5xl font-semibold tracking-tight text-base-100 sm:text-7xl"> 23 29 skywatched 24 30 </h1> 25 - <p class="text-md mt-8 text-pretty font-medium text-base-400 sm:text-lg"> 26 - social media for movies based on at proto. work in progress. 31 + <p class="text-md mt-8 text-pretty font-medium text-base-300 sm:text-lg"> 32 + review movies and shows with your bluesky account. 27 33 </p> 34 + {#if !data.user} 35 + <button 36 + onclick={() => { 37 + showLoginModal.toggle(); 38 + }} 39 + type="button" 40 + class="mt-8 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" 41 + > 42 + Sign in with Bluesky 43 + </button> 44 + {:else} 45 + <button 46 + onclick={() => { 47 + rateMovieModal.showEmpty(); 48 + }} 49 + type="button" 50 + class="mt-8 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" 51 + > 52 + New review 53 + </button> 54 + {/if} 28 55 </div> 29 56 30 57 <div
+3 -1
src/lib/Components/Footer.svelte
··· 19 19 </a> 20 20 </div> 21 21 <div class="mt-8 flex flex-col items-center md:order-1 md:items-start"> 22 - <img src="/tmdb_logo.svg" alt="TMDB logo" class="h-3" /> 22 + <a href="https://www.themoviedb.org/" target="_blank"> 23 + <img src="/tmdb_logo.svg" alt="TMDB logo" class="h-3" /> 24 + </a> 23 25 <p class="mt-2 text-center text-xs text-base-400"> 24 26 This website uses the TMDB API but is not endorsed or certified by TMDB. 25 27 </p>
+5 -5
src/lib/Components/ItemCard.svelte
··· 25 25 > 26 26 {#if item.poster_path} 27 27 <img 28 - src="https://image.tmdb.org/t/p/w500{item.poster_path}" 28 + src="https://image.tmdb.org/t/p/w342{item.poster_path}" 29 29 alt="movie poster for {item.title ?? item.name}" 30 30 class="size-full object-cover object-center lg:size-full" 31 31 /> ··· 55 55 : 'hover:border-accent-800/50 hover:bg-accent-900/50 hover:text-accent-400 hover:duration-0' 56 56 )} 57 57 onclick={() => { 58 - rateMovieModal.selectedItem = { 58 + rateMovieModal.show({ 59 59 movieId: item.movieId, 60 60 showId: item.showId, 61 61 kind: item.movieId ? 'movie' : 'tv', 62 62 name: item.title ?? item.name, 63 63 currentRating: 0, 64 - currentReview: '' 65 - }; 66 - rateMovieModal.showModal = true; 64 + currentReview: '', 65 + posterPath: item.poster_path 66 + }); 67 67 }} 68 68 > 69 69 <span class="sr-only">rate</span>
+6 -2
src/lib/Components/LoginModal.svelte
··· 52 52 > 53 53 </div> 54 54 55 - <div class="mt-4 text-sm text-base-300 border-t border-base-700 pt-4 leading-7"> 55 + <div class="mt-4 border-t border-base-700 pt-4 text-sm leading-7 text-base-300"> 56 56 Don't have an account? 57 57 <br /> 58 - <a href="https://bsky.app" target="_blank" class="text-accent-400 hover:text-accent-500 font-medium"> 58 + <a 59 + href="https://bsky.app" 60 + target="_blank" 61 + class="font-medium text-accent-400 hover:text-accent-500" 62 + > 59 63 Create one on bluesky 60 64 </a> and come back here. 61 65 </div>
+11 -3
src/lib/Components/Profile.svelte
··· 19 19 <div class="aspect-[8/1] w-full"></div> 20 20 {/if} 21 21 </div> 22 - <div class={cn(profile.banner ? '-mt-11' : '-mt-8', "sm:-mt-16 flex items-end space-x-5 px-4 sm:px-6 lg:px-8")}> 22 + <div 23 + class={cn( 24 + profile.banner ? '-mt-11' : '-mt-8', 25 + 'flex items-end space-x-5 px-4 sm:-mt-16 sm:px-6 lg:px-8' 26 + )} 27 + > 23 28 <Avatar src={profile.avatar} size="size-24 sm:size-32" /> 24 29 <div 25 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" 26 31 > 27 32 <div 28 - class={cn(profile.banner ? 'mt-4 sm:mt-0' : '-mt-[4.5rem] sm:-mt-[6.5rem]', "flex min-w-0 flex-1 flex-col items-baseline")} 33 + class={cn( 34 + profile.banner ? 'mt-4 sm:mt-0' : '-mt-[4.5rem] sm:-mt-[6.5rem]', 35 + 'flex min-w-0 flex-1 flex-col items-baseline' 36 + )} 29 37 > 30 38 <h1 class="truncate text-xl font-bold text-base-900 dark:text-base-100"> 31 39 {profile.displayName || profile.handle} 32 40 </h1> 33 - <div class="text-sm truncate text-base-900 dark:text-base-400"> 41 + <div class="truncate text-sm text-base-900 dark:text-base-400"> 34 42 @{profile.handle} 35 43 </div> 36 44 </div>
+55 -8
src/lib/Components/RateMovieModal.svelte
··· 2 2 import { rateMovieModal, watchedItems } from '$lib/state.svelte'; 3 3 import { toast } from 'svelte-sonner'; 4 4 import Rating from './Rating.svelte'; 5 + import SearchCombobox from './SearchCombobox.svelte'; 5 6 6 7 let rating = $state(rateMovieModal.selectedItem.currentRating ?? 0); 7 8 let review = $state(rateMovieModal.selectedItem.currentReview ?? ''); ··· 24 25 <div 25 26 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" 26 27 > 28 + <button 29 + class="absolute right-2 top-2 rounded-full bg-base-800/50 p-1 hover:bg-base-800/80" 30 + onclick={() => (rateMovieModal.showModal = false)} 31 + > 32 + <svg 33 + xmlns="http://www.w3.org/2000/svg" 34 + fill="none" 35 + viewBox="0 0 24 24" 36 + stroke-width="1.5" 37 + stroke="currentColor" 38 + class="size-4" 39 + > 40 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 41 + </svg> 42 + 43 + <span class="sr-only">close</span> 44 + </button> 45 + 27 46 <div> 28 - <h3 class="text-lg font-semibold text-base-50" id="modal-title"> 29 - Rate {rateMovieModal.selectedItem.name} 47 + <h3 class="text-md mb-4 font-semibold text-base-50" id="modal-title"> 48 + Rate and review 30 49 </h3> 31 50 32 - <div class="mt-4 flex items-center gap-2"> 33 - <p class="text-xs font-medium text-base-50">stars</p> 51 + {#if rateMovieModal.selectedItem.name} 52 + <div class="relative flex items-center gap-4"> 53 + <div 54 + 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" 55 + > 56 + {#if rateMovieModal.selectedItem.posterPath} 57 + <img 58 + src="https://image.tmdb.org/t/p/w154{rateMovieModal.selectedItem.posterPath}" 59 + alt="movie poster for {rateMovieModal.selectedItem.name}" 60 + class="size-full object-cover object-center lg:size-full" 61 + /> 62 + {/if} 63 + </div> 64 + <h3 65 + class="mb-4 flex flex-col gap-2 text-xl font-semibold text-base-50" 66 + id="modal-title" 67 + > 68 + {rateMovieModal.selectedItem.name} 34 69 35 - <Rating bind:rating canChange /> 36 - </div> 70 + <Rating bind:rating canChange size="size-7" /> 71 + </h3> 72 + <!-- <div class="absolute right-2 top-0"> 73 + <button 74 + onclick={() => (rateMovieModal.showModal = false)} 75 + 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" 76 + > 77 + change 78 + </button> 79 + </div> --> 80 + </div> 81 + {:else} 82 + <SearchCombobox /> 83 + {/if} 37 84 38 85 <div class="mt-4"> 39 86 <label for="comment" class="block text-xs font-medium text-base-50">review</label> ··· 44 91 id="comment" 45 92 bind:value={review} 46 93 placeholder="write a review" 47 - class="block w-full rounded-md bg-base-950 px-3 py-1.5 text-base text-base-50 outline outline-1 -outline-offset-1 outline-base-700 placeholder:text-base-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-accent-600 sm:text-sm/6" 94 + 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" 48 95 ></textarea> 49 96 </div> 50 97 </div> ··· 74 121 }} 75 122 type="button" 76 123 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" 77 - >save</button 124 + >review</button 78 125 > 79 126 </div> 80 127 </div>
+6 -2
src/lib/Components/ReviewCard.svelte
··· 7 7 $props(); 8 8 </script> 9 9 10 - <div class="w-full max-w-2xl p-6 backdrop-blur-sm"> 10 + <div class="relative w-full max-w-2xl rounded-xl p-6 backdrop-blur-sm"> 11 11 <div class="flex items-center gap-4"> 12 12 {#if showMovieDetails} 13 13 <a ··· 18 18 > 19 19 {#if data.record.metadata?.poster_path} 20 20 <img 21 - src="https://image.tmdb.org/t/p/w500{data.record.metadata.poster_path}" 21 + src="https://image.tmdb.org/t/p/w154{data.record.metadata.poster_path}" 22 22 alt="movie poster for {data.record.metadata.title}" 23 23 class="size-full object-cover object-center lg:size-full" 24 24 /> ··· 65 65 {@html data.record.note?.value?.replace('\n', '<br /><br />')} 66 66 </div> 67 67 {/if} 68 + 69 + <!-- <a href={`/review/${encodeURIComponent(data.uri)}`}> 70 + <span class="absolute inset-0 z-10"></span> 71 + </a> --> 68 72 </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 < 3) { 34 + results = []; 35 + } else { 36 + searching = true; 37 + debounce(async () => { 38 + if ($inputValue.length < 3) 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 > 2} 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 > 2} 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 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 items-center justify-center 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 > 2} 160 + No results found 161 + {/if} 162 + </li> 163 + {/each} 164 + </div> 165 + </ul> 166 + {/if}
+1 -1
src/lib/Components/Sidebar.svelte
··· 40 40 href: '/review/new', 41 41 onclick: () => { 42 42 showSidebar.value = false; 43 - rateMovieModal.showModal = true; 43 + rateMovieModal.showEmpty(); 44 44 } 45 45 } 46 46 ];
+1 -1
src/lib/Components/User.svelte
··· 16 16 <span class="sr-only">Open user menu</span> 17 17 <div 18 18 class={cn( 19 - 'size-8 overflow-hidden rounded-full border border-base-400/50 bg-base-100 transition-colors duration-100 hover:border-accent-400 hover:border-[1.5px] dark:border-base-700 dark:bg-base-800', 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 20 false 21 21 ? 'border-accent-300/50 bg-accent-100/50 dark:border-accent-500/50 dark:bg-accent-800/50' 22 22 : ''
-81
src/lib/server/auth.ts
··· 1 - import type { RequestEvent } from '@sveltejs/kit'; 2 - import { eq } from 'drizzle-orm'; 3 - import { sha256 } from '@oslojs/crypto/sha2'; 4 - import { encodeBase32LowerCase, encodeHexLowerCase } from '@oslojs/encoding'; 5 - import { db } from '$lib/server/db'; 6 - import * as table from '$lib/server/db/schema'; 7 - 8 - const DAY_IN_MS = 1000 * 60 * 60 * 24; 9 - 10 - export const sessionCookieName = 'auth-session'; 11 - 12 - export function generateSessionToken() { 13 - const bytes = crypto.getRandomValues(new Uint8Array(20)); 14 - const token = encodeBase32LowerCase(bytes); 15 - return token; 16 - } 17 - 18 - export async function createSession(token: string, userId: string) { 19 - const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 20 - const session: table.Session = { 21 - id: sessionId, 22 - userId, 23 - expiresAt: new Date(Date.now() + DAY_IN_MS * 30) 24 - }; 25 - await db.insert(table.session).values(session); 26 - return session; 27 - } 28 - 29 - export async function validateSessionToken(token: string) { 30 - const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))); 31 - const [result] = await db 32 - .select({ 33 - // Adjust user table here to tweak returned data 34 - user: { id: table.user.id, username: table.user.username }, 35 - session: table.session 36 - }) 37 - .from(table.session) 38 - .innerJoin(table.user, eq(table.session.userId, table.user.id)) 39 - .where(eq(table.session.id, sessionId)); 40 - 41 - if (!result) { 42 - return { session: null, user: null }; 43 - } 44 - const { session, user } = result; 45 - 46 - const sessionExpired = Date.now() >= session.expiresAt.getTime(); 47 - if (sessionExpired) { 48 - await db.delete(table.session).where(eq(table.session.id, session.id)); 49 - return { session: null, user: null }; 50 - } 51 - 52 - const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15; 53 - if (renewSession) { 54 - session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30); 55 - await db 56 - .update(table.session) 57 - .set({ expiresAt: session.expiresAt }) 58 - .where(eq(table.session.id, session.id)); 59 - } 60 - 61 - return { session, user }; 62 - } 63 - 64 - export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>; 65 - 66 - export async function invalidateSession(sessionId: string) { 67 - await db.delete(table.session).where(eq(table.session.id, sessionId)); 68 - } 69 - 70 - export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) { 71 - event.cookies.set(sessionCookieName, token, { 72 - expires: expiresAt, 73 - path: '/' 74 - }); 75 - } 76 - 77 - export function deleteSessionTokenCookie(event: RequestEvent) { 78 - event.cookies.delete(sessionCookieName, { 79 - path: '/' 80 - }); 81 - }
+2 -3
src/lib/server/client.ts
··· 6 6 const publicUrl = 'https://skywatched.app'; 7 7 const port = 5173; 8 8 const url = dev ? `http://[::1]:${port}` : publicUrl; 9 - const enc = encodeURIComponent; 10 9 11 - const encodeCallbackUrl = enc(`${url}/oauth/callback`); 12 - const devClientId = `http://localhost?redirect_uri=${encodeCallbackUrl}&scope=${enc('atproto transition:generic')}`; 10 + const encodeCallbackUrl = encodeURIComponent(`${url}/oauth/callback`); 11 + const devClientId = `http://localhost?redirect_uri=${encodeCallbackUrl}&scope=${encodeURIComponent('atproto transition:generic')}`; 13 12 const clientId = !dev ? `${publicUrl}/client-metadata.json` : devClientId; 14 13 15 14 export const atclient = new NodeOAuthClient({
+8 -6
src/routes/feed/+page.svelte
··· 12 12 </svelte:head> 13 13 14 14 <Container class="relative z-10 py-8 sm:py-16"> 15 - <div class="flex flex-col gap-4 px-4"> 16 - <h1 class="pb-4 text-2xl font-bold">Recent reviews</h1> 17 - </div> 15 + <div class="mx-auto max-w-2xl"> 16 + <div class="flex flex-col gap-4 px-4"> 17 + <h1 class="pb-4 text-2xl font-bold">Recent reviews</h1> 18 + </div> 18 19 19 - {#if data.feed.length > 0} 20 - <ReviewList reviews={data.feed} /> 21 - {/if} 20 + {#if data.feed.length > 0} 21 + <ReviewList reviews={data.feed} /> 22 + {/if} 23 + </div> 22 24 </Container>
+4 -105
src/routes/login/+page.server.ts
··· 1 - import { hash, verify } from '@node-rs/argon2'; 2 - import { encodeBase32LowerCase } from '@oslojs/encoding'; 3 - import { fail, redirect } from '@sveltejs/kit'; 4 - import { eq } from 'drizzle-orm'; 5 - import * as auth from '$lib/server/auth'; 6 - import { db } from '$lib/server/db'; 7 - import * as table from '$lib/server/db/schema'; 1 + import { redirect } from '@sveltejs/kit'; 2 + 8 3 import type { Actions, PageServerLoad } from './$types'; 9 4 10 5 export const load: PageServerLoad = async (event) => { 11 6 if (event.locals.user) { 12 - return redirect(302, '/user/' + event.locals.user.username); 7 + return redirect(302, '/user/' + event.locals.user.handle); 13 8 } 14 9 return {}; 15 10 }; 16 11 17 12 export const actions: Actions = { 18 - login: async (event) => { 19 - const formData = await event.request.formData(); 20 - const username = formData.get('username'); 21 - const password = formData.get('password'); 22 - 23 - if (!validateUsername(username)) { 24 - return fail(400, { message: 'Invalid username' }); 25 - } 26 - if (!validatePassword(password)) { 27 - return fail(400, { message: 'Invalid password' }); 28 - } 29 - 30 - const results = await db.select().from(table.user).where(eq(table.user.username, username)); 31 - 32 - const existingUser = results.at(0); 33 - if (!existingUser) { 34 - return fail(400, { message: 'Incorrect username or password' }); 35 - } 36 - 37 - const validPassword = await verify(existingUser.passwordHash, password, { 38 - memoryCost: 19456, 39 - timeCost: 2, 40 - outputLen: 32, 41 - parallelism: 1 42 - }); 43 - if (!validPassword) { 44 - return fail(400, { message: 'Incorrect username or password' }); 45 - } 46 - 47 - const sessionToken = auth.generateSessionToken(); 48 - const session = await auth.createSession(sessionToken, existingUser.id); 49 - auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); 50 - 51 - return redirect(302, '/user/' + existingUser.username); 52 - }, 53 - register: async (event) => { 54 - const formData = await event.request.formData(); 55 - const username = formData.get('username'); 56 - const password = formData.get('password'); 57 - 58 - if (!validateUsername(username)) { 59 - return fail(400, { 60 - message: 61 - 'username must be between 3 and 31 characters and only contain letters, numbers, underscores, and hyphens' 62 - }); 63 - } 64 - if (!validatePassword(password)) { 65 - return fail(400, { message: 'password must be between 6 and 255 characters' }); 66 - } 67 - 68 - const userId = generateUserId(); 69 - const passwordHash = await hash(password, { 70 - // recommended minimum parameters 71 - memoryCost: 19456, 72 - timeCost: 2, 73 - outputLen: 32, 74 - parallelism: 1 75 - }); 76 - 77 - // check if username is already taken 78 - const results = await db.select().from(table.user).where(eq(table.user.username, username)); 79 - if (results.length > 0) { 80 - return fail(400, { message: 'username already taken' }); 81 - } 82 - 83 - try { 84 - await db.insert(table.user).values({ id: userId, username, passwordHash }); 85 - 86 - const sessionToken = auth.generateSessionToken(); 87 - const session = await auth.createSession(sessionToken, userId); 88 - auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); 89 - } catch (e) { 90 - console.error(e); 91 - return fail(500, { message: 'An error has occurred' }); 92 - } 93 - return redirect(302, '/user/' + username); 94 - } 13 + login: async (event) => {} 95 14 }; 96 - 97 - function generateUserId() { 98 - // ID with 120 bits of entropy, or about the same as UUID v4. 99 - const bytes = crypto.getRandomValues(new Uint8Array(15)); 100 - const id = encodeBase32LowerCase(bytes); 101 - return id; 102 - } 103 - 104 - function validateUsername(username: unknown): username is string { 105 - return ( 106 - typeof username === 'string' && 107 - username.length >= 3 && 108 - username.length <= 31 && 109 - /^[a-zA-Z0-9_-]+$/.test(username) 110 - ); 111 - } 112 - 113 - function validatePassword(password: unknown): password is string { 114 - return typeof password === 'string' && password.length >= 6 && password.length <= 255; 115 - }
+36
src/routes/onboarding/+page.server.ts
··· 1 + import { error, redirect, type Actions } from '@sveltejs/kit'; 2 + import available_regions from './available_regions.json'; 3 + import { AtpBaseClient } from '@atproto/api'; 4 + 5 + export const actions: Actions = { 6 + settings: async ({ request, locals }) => { 7 + const agent = locals.agent; 8 + if (!locals.user || !agent || agent instanceof AtpBaseClient) { 9 + return error(401, 'Unauthorized call'); 10 + } 11 + 12 + const formData = await request.formData(); 13 + const crosspost = (formData.get('crosspost-enabled') ?? 'false') as string; 14 + 15 + console.log(formData); 16 + 17 + const region = formData.get('region') as string; 18 + const region_data = available_regions.find((r) => r.english_name === region); 19 + console.log(region_data); 20 + 21 + await agent.com.atproto.repo.putRecord({ 22 + repo: locals.user.did, 23 + collection: 'app.skywatched.settings', 24 + rkey: 'self', 25 + record: { 26 + streaming_region: { 27 + code: region_data?.iso_3166_1, 28 + name: region_data?.english_name 29 + }, 30 + crosspost_enabled: crosspost === 'true' 31 + } 32 + }); 33 + 34 + return redirect(302, '/user/' + locals.user.handle); 35 + } 36 + };
+188
src/routes/onboarding/+page.svelte
··· 1 + <script lang="ts"> 2 + import { createCombobox, melt, type ComboboxOptionProps } from '@melt-ui/svelte'; 3 + import { fly } from 'svelte/transition'; 4 + 5 + import availableRegions from './available_regions.json'; 6 + 7 + type Region = { 8 + iso_3166_1: string; 9 + english_name: string; 10 + native_name: string; 11 + }; 12 + 13 + const toOption = (region: Region): ComboboxOptionProps<Region> => ({ 14 + value: region, 15 + label: region.english_name, 16 + disabled: false 17 + }); 18 + 19 + const { 20 + elements: { menu, input, option, label }, 21 + states: { open, inputValue, touchedInput, selected }, 22 + helpers: { isSelected } 23 + } = createCombobox<Region>({ 24 + forceVisible: true 25 + }); 26 + 27 + $: if (!$open) { 28 + $inputValue = $selected?.label ?? ''; 29 + } 30 + 31 + $: filteredRegions = $touchedInput 32 + ? availableRegions.filter(({ english_name, native_name, iso_3166_1 }) => { 33 + const normalizedInput = $inputValue.toLowerCase(); 34 + return ( 35 + english_name.toLowerCase().includes(normalizedInput) || 36 + native_name.toLowerCase().includes(normalizedInput) || 37 + iso_3166_1.toLowerCase().includes(normalizedInput) 38 + ); 39 + }) 40 + : availableRegions; 41 + </script> 42 + 43 + <div class="flex h-screen flex-col items-center justify-center"> 44 + <form class="w-full max-w-lg px-4" action="?/settings" method="POST"> 45 + <div class="mb-12 text-2xl font-bold text-base-50">Welcome to Skywatched!</div> 46 + 47 + <div class="flex w-full flex-col gap-1"> 48 + <!-- svelte-ignore a11y-label-has-associated-control - $label contains the 'for' attribute --> 49 + <label use:melt={$label}> 50 + <span class="text-sm/6 font-semibold text-base-50">Streaming region</span> 51 + </label> 52 + <p class="mt-1 max-w-sm text-sm/6 text-base-400"> 53 + Optionally select your streaming region, so we can show you where you can watch movies. 54 + </p> 55 + 56 + <div class="relative mt-4"> 57 + <input 58 + use:melt={$input} 59 + class="flex h-10 w-full items-center justify-between rounded-lg border-base-800 bg-black px-3 pr-12 text-base-50 60 + placeholder:text-base-400 focus:border-none focus:outline-none focus:ring-2 focus:ring-accent-500" 61 + placeholder="Streaming region" 62 + name="region" 63 + /> 64 + <div class="absolute right-2 top-1/2 z-10 -translate-y-1/2 text-accent-500"> 65 + {#if $open} 66 + <svg 67 + xmlns="http://www.w3.org/2000/svg" 68 + fill="none" 69 + viewBox="0 0 24 24" 70 + stroke-width="1.5" 71 + stroke="currentColor" 72 + class="size-4" 73 + > 74 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" /> 75 + </svg> 76 + {:else} 77 + <svg 78 + xmlns="http://www.w3.org/2000/svg" 79 + fill="none" 80 + viewBox="0 0 24 24" 81 + stroke-width="1.5" 82 + stroke="currentColor" 83 + class="size-4" 84 + > 85 + <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /> 86 + </svg> 87 + {/if} 88 + </div> 89 + </div> 90 + </div> 91 + 92 + {#if $open} 93 + <ul 94 + class=" z-10 flex max-h-[300px] flex-col overflow-hidden rounded-lg" 95 + use:melt={$menu} 96 + transition:fly={{ duration: 150, y: -5 }} 97 + > 98 + <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 99 + <div 100 + class="flex max-h-full flex-col gap-0 overflow-y-auto bg-base-900 px-2 py-2 text-base-200" 101 + tabindex="0" 102 + > 103 + {#each filteredRegions as region, index (index)} 104 + <li 105 + use:melt={$option(toOption(region))} 106 + class="relative cursor-pointer scroll-my-2 rounded-md py-2 pl-4 pr-4 107 + hover:bg-accent-950/50 108 + data-[highlighted]:bg-accent-950/50 data-[highlighted]:text-accent-200 109 + data-[disabled]:opacity-50" 110 + > 111 + {#if $isSelected(region)} 112 + <div class="check absolute left-2 top-1/2 z-10 text-accent-900"> 113 + <svg 114 + xmlns="http://www.w3.org/2000/svg" 115 + fill="none" 116 + viewBox="0 0 24 24" 117 + stroke-width="1.5" 118 + stroke="currentColor" 119 + class="size-4" 120 + > 121 + <path 122 + stroke-linecap="round" 123 + stroke-linejoin="round" 124 + d="m4.5 12.75 6 6 9-13.5" 125 + /> 126 + </svg> 127 + </div> 128 + {/if} 129 + <div class="pl-4"> 130 + <span class="font-medium">{region.english_name}</span> 131 + </div> 132 + </li> 133 + {:else} 134 + <li class="relative cursor-pointer rounded-md py-1 pl-8 pr-4">No results found</li> 135 + {/each} 136 + </div> 137 + </ul> 138 + {/if} 139 + 140 + <fieldset class="mt-8"> 141 + <legend class="text-sm/6 font-semibold text-base-50">Crossposting</legend> 142 + <p class="mt-1 max-w-sm text-sm/6 text-base-400"> 143 + Do you want to enable crossposting reviews to bluesky? You can still change this per review 144 + later. 145 + </p> 146 + <div class="mt-6 space-y-4"> 147 + <div class="flex items-center"> 148 + <input 149 + id="crosspost-enabled" 150 + name="crosspost-enabled" 151 + type="radio" 152 + checked 153 + value="true" 154 + class="relative size-4 appearance-none rounded-full border border-base-700 bg-white/10 before:absolute before:inset-1 before:rounded-full before:bg-white checked:border-accent-500 checked:bg-accent-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-400 disabled:border-base-300 disabled:bg-base-100 disabled:before:bg-base-400 forced-colors:appearance-auto forced-colors:before:hidden [&:not(:checked)]:before:hidden" 155 + /> 156 + <label for="crosspost-enabled" class="ml-3 block text-sm/6 font-medium text-base-50" 157 + >Enable</label 158 + > 159 + </div> 160 + <div class="flex items-center"> 161 + <input 162 + id="crosspost-disabled" 163 + name="crosspost-enabled" 164 + type="radio" 165 + value="false" 166 + class="relative size-4 appearance-none rounded-full border border-base-700 bg-white/10 before:absolute before:inset-1 before:rounded-full before:bg-white checked:border-accent-500 checked:bg-accent-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-400 disabled:border-base-300 disabled:bg-base-100 disabled:before:bg-base-400 forced-colors:appearance-auto forced-colors:before:hidden [&:not(:checked)]:before:hidden" 167 + /> 168 + <label for="crosspost-disabled" class="ml-3 block text-sm/6 font-medium text-base-50" 169 + >Disable</label 170 + > 171 + </div> 172 + </div> 173 + </fieldset> 174 + 175 + <button 176 + type="submit" 177 + class="mt-12 flex w-full justify-center rounded-md 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" 178 + >Save</button 179 + > 180 + </form> 181 + </div> 182 + 183 + <style lang="postcss"> 184 + .check { 185 + @apply absolute left-2 top-1/2 text-accent-500; 186 + translate: 0 calc(-50% + 1px); 187 + } 188 + </style>
+697
src/routes/onboarding/available_regions.json
··· 1 + [ 2 + { 3 + "iso_3166_1": "AD", 4 + "english_name": "Andorra", 5 + "native_name": "Andorra" 6 + }, 7 + { 8 + "iso_3166_1": "AE", 9 + "english_name": "United Arab Emirates", 10 + "native_name": "United Arab Emirates" 11 + }, 12 + { 13 + "iso_3166_1": "AG", 14 + "english_name": "Antigua and Barbuda", 15 + "native_name": "Antigua & Barbuda" 16 + }, 17 + { 18 + "iso_3166_1": "AL", 19 + "english_name": "Albania", 20 + "native_name": "Albania" 21 + }, 22 + { 23 + "iso_3166_1": "AO", 24 + "english_name": "Angola", 25 + "native_name": "Angola" 26 + }, 27 + { 28 + "iso_3166_1": "AR", 29 + "english_name": "Argentina", 30 + "native_name": "Argentina" 31 + }, 32 + { 33 + "iso_3166_1": "AT", 34 + "english_name": "Austria", 35 + "native_name": "Austria" 36 + }, 37 + { 38 + "iso_3166_1": "AU", 39 + "english_name": "Australia", 40 + "native_name": "Australia" 41 + }, 42 + { 43 + "iso_3166_1": "AZ", 44 + "english_name": "Azerbaijan", 45 + "native_name": "Azerbaijan" 46 + }, 47 + { 48 + "iso_3166_1": "BA", 49 + "english_name": "Bosnia and Herzegovina", 50 + "native_name": "Bosnia & Herzegovina" 51 + }, 52 + { 53 + "iso_3166_1": "BB", 54 + "english_name": "Barbados", 55 + "native_name": "Barbados" 56 + }, 57 + { 58 + "iso_3166_1": "BE", 59 + "english_name": "Belgium", 60 + "native_name": "Belgium" 61 + }, 62 + { 63 + "iso_3166_1": "BF", 64 + "english_name": "Burkina Faso", 65 + "native_name": "Burkina Faso" 66 + }, 67 + { 68 + "iso_3166_1": "BG", 69 + "english_name": "Bulgaria", 70 + "native_name": "Bulgaria" 71 + }, 72 + { 73 + "iso_3166_1": "BH", 74 + "english_name": "Bahrain", 75 + "native_name": "Bahrain" 76 + }, 77 + { 78 + "iso_3166_1": "BM", 79 + "english_name": "Bermuda", 80 + "native_name": "Bermuda" 81 + }, 82 + { 83 + "iso_3166_1": "BO", 84 + "english_name": "Bolivia", 85 + "native_name": "Bolivia" 86 + }, 87 + { 88 + "iso_3166_1": "BR", 89 + "english_name": "Brazil", 90 + "native_name": "Brazil" 91 + }, 92 + { 93 + "iso_3166_1": "BS", 94 + "english_name": "Bahamas", 95 + "native_name": "Bahamas" 96 + }, 97 + { 98 + "iso_3166_1": "BY", 99 + "english_name": "Belarus", 100 + "native_name": "Belarus" 101 + }, 102 + { 103 + "iso_3166_1": "BZ", 104 + "english_name": "Belize", 105 + "native_name": "Belize" 106 + }, 107 + { 108 + "iso_3166_1": "CA", 109 + "english_name": "Canada", 110 + "native_name": "Canada" 111 + }, 112 + { 113 + "iso_3166_1": "CD", 114 + "english_name": "Congo", 115 + "native_name": "Democratic Republic of the Congo (Kinshasa)" 116 + }, 117 + { 118 + "iso_3166_1": "CH", 119 + "english_name": "Switzerland", 120 + "native_name": "Switzerland" 121 + }, 122 + { 123 + "iso_3166_1": "CI", 124 + "english_name": "Cote D'Ivoire", 125 + "native_name": "Côte d’Ivoire" 126 + }, 127 + { 128 + "iso_3166_1": "CL", 129 + "english_name": "Chile", 130 + "native_name": "Chile" 131 + }, 132 + { 133 + "iso_3166_1": "CM", 134 + "english_name": "Cameroon", 135 + "native_name": "Cameroon" 136 + }, 137 + { 138 + "iso_3166_1": "CO", 139 + "english_name": "Colombia", 140 + "native_name": "Colombia" 141 + }, 142 + { 143 + "iso_3166_1": "CR", 144 + "english_name": "Costa Rica", 145 + "native_name": "Costa Rica" 146 + }, 147 + { 148 + "iso_3166_1": "CU", 149 + "english_name": "Cuba", 150 + "native_name": "Cuba" 151 + }, 152 + { 153 + "iso_3166_1": "CV", 154 + "english_name": "Cape Verde", 155 + "native_name": "Cape Verde" 156 + }, 157 + { 158 + "iso_3166_1": "CY", 159 + "english_name": "Cyprus", 160 + "native_name": "Cyprus" 161 + }, 162 + { 163 + "iso_3166_1": "CZ", 164 + "english_name": "Czech Republic", 165 + "native_name": "Czech Republic" 166 + }, 167 + { 168 + "iso_3166_1": "DE", 169 + "english_name": "Germany", 170 + "native_name": "Deutschland" 171 + }, 172 + { 173 + "iso_3166_1": "DK", 174 + "english_name": "Denmark", 175 + "native_name": "Denmark" 176 + }, 177 + { 178 + "iso_3166_1": "DO", 179 + "english_name": "Dominican Republic", 180 + "native_name": "Dominican Republic" 181 + }, 182 + { 183 + "iso_3166_1": "DZ", 184 + "english_name": "Algeria", 185 + "native_name": "Algeria" 186 + }, 187 + { 188 + "iso_3166_1": "EC", 189 + "english_name": "Ecuador", 190 + "native_name": "Ecuador" 191 + }, 192 + { 193 + "iso_3166_1": "EE", 194 + "english_name": "Estonia", 195 + "native_name": "Estonia" 196 + }, 197 + { 198 + "iso_3166_1": "EG", 199 + "english_name": "Egypt", 200 + "native_name": "Egypt" 201 + }, 202 + { 203 + "iso_3166_1": "ES", 204 + "english_name": "Spain", 205 + "native_name": "Spain" 206 + }, 207 + { 208 + "iso_3166_1": "FI", 209 + "english_name": "Finland", 210 + "native_name": "Finland" 211 + }, 212 + { 213 + "iso_3166_1": "FJ", 214 + "english_name": "Fiji", 215 + "native_name": "Fiji" 216 + }, 217 + { 218 + "iso_3166_1": "FR", 219 + "english_name": "France", 220 + "native_name": "France" 221 + }, 222 + { 223 + "iso_3166_1": "GB", 224 + "english_name": "United Kingdom", 225 + "native_name": "United Kingdom" 226 + }, 227 + { 228 + "iso_3166_1": "GF", 229 + "english_name": "French Guiana", 230 + "native_name": "French Guiana" 231 + }, 232 + { 233 + "iso_3166_1": "GH", 234 + "english_name": "Ghana", 235 + "native_name": "Ghana" 236 + }, 237 + { 238 + "iso_3166_1": "GI", 239 + "english_name": "Gibraltar", 240 + "native_name": "Gibraltar" 241 + }, 242 + { 243 + "iso_3166_1": "GP", 244 + "english_name": "Guadaloupe", 245 + "native_name": "Guadeloupe" 246 + }, 247 + { 248 + "iso_3166_1": "GQ", 249 + "english_name": "Equatorial Guinea", 250 + "native_name": "Equatorial Guinea" 251 + }, 252 + { 253 + "iso_3166_1": "GR", 254 + "english_name": "Greece", 255 + "native_name": "Greece" 256 + }, 257 + { 258 + "iso_3166_1": "GT", 259 + "english_name": "Guatemala", 260 + "native_name": "Guatemala" 261 + }, 262 + { 263 + "iso_3166_1": "GY", 264 + "english_name": "Guyana", 265 + "native_name": "Guyana" 266 + }, 267 + { 268 + "iso_3166_1": "HK", 269 + "english_name": "Hong Kong", 270 + "native_name": "Hong Kong SAR China" 271 + }, 272 + { 273 + "iso_3166_1": "HN", 274 + "english_name": "Honduras", 275 + "native_name": "Honduras" 276 + }, 277 + { 278 + "iso_3166_1": "HR", 279 + "english_name": "Croatia", 280 + "native_name": "Croatia" 281 + }, 282 + { 283 + "iso_3166_1": "HU", 284 + "english_name": "Hungary", 285 + "native_name": "Hungary" 286 + }, 287 + { 288 + "iso_3166_1": "ID", 289 + "english_name": "Indonesia", 290 + "native_name": "Indonesia" 291 + }, 292 + { 293 + "iso_3166_1": "IE", 294 + "english_name": "Ireland", 295 + "native_name": "Ireland" 296 + }, 297 + { 298 + "iso_3166_1": "IL", 299 + "english_name": "Israel", 300 + "native_name": "Israel" 301 + }, 302 + { 303 + "iso_3166_1": "IN", 304 + "english_name": "India", 305 + "native_name": "India" 306 + }, 307 + { 308 + "iso_3166_1": "IQ", 309 + "english_name": "Iraq", 310 + "native_name": "Iraq" 311 + }, 312 + { 313 + "iso_3166_1": "IS", 314 + "english_name": "Iceland", 315 + "native_name": "Iceland" 316 + }, 317 + { 318 + "iso_3166_1": "IT", 319 + "english_name": "Italy", 320 + "native_name": "Italy" 321 + }, 322 + { 323 + "iso_3166_1": "JM", 324 + "english_name": "Jamaica", 325 + "native_name": "Jamaica" 326 + }, 327 + { 328 + "iso_3166_1": "JO", 329 + "english_name": "Jordan", 330 + "native_name": "Jordan" 331 + }, 332 + { 333 + "iso_3166_1": "JP", 334 + "english_name": "Japan", 335 + "native_name": "Japan" 336 + }, 337 + { 338 + "iso_3166_1": "KE", 339 + "english_name": "Kenya", 340 + "native_name": "Kenya" 341 + }, 342 + { 343 + "iso_3166_1": "KR", 344 + "english_name": "South Korea", 345 + "native_name": "South Korea" 346 + }, 347 + { 348 + "iso_3166_1": "KW", 349 + "english_name": "Kuwait", 350 + "native_name": "Kuwait" 351 + }, 352 + { 353 + "iso_3166_1": "LB", 354 + "english_name": "Lebanon", 355 + "native_name": "Lebanon" 356 + }, 357 + { 358 + "iso_3166_1": "LC", 359 + "english_name": "St. Lucia", 360 + "native_name": "St. Lucia" 361 + }, 362 + { 363 + "iso_3166_1": "LI", 364 + "english_name": "Liechtenstein", 365 + "native_name": "Liechtenstein" 366 + }, 367 + { 368 + "iso_3166_1": "LT", 369 + "english_name": "Lithuania", 370 + "native_name": "Lithuania" 371 + }, 372 + { 373 + "iso_3166_1": "LU", 374 + "english_name": "Luxembourg", 375 + "native_name": "Luxembourg" 376 + }, 377 + { 378 + "iso_3166_1": "LV", 379 + "english_name": "Latvia", 380 + "native_name": "Latvia" 381 + }, 382 + { 383 + "iso_3166_1": "LY", 384 + "english_name": "Libyan Arab Jamahiriya", 385 + "native_name": "Libya" 386 + }, 387 + { 388 + "iso_3166_1": "MA", 389 + "english_name": "Morocco", 390 + "native_name": "Morocco" 391 + }, 392 + { 393 + "iso_3166_1": "MC", 394 + "english_name": "Monaco", 395 + "native_name": "Monaco" 396 + }, 397 + { 398 + "iso_3166_1": "MD", 399 + "english_name": "Moldova", 400 + "native_name": "Moldova" 401 + }, 402 + { 403 + "iso_3166_1": "ME", 404 + "english_name": "Montenegro", 405 + "native_name": "Montenegro" 406 + }, 407 + { 408 + "iso_3166_1": "MG", 409 + "english_name": "Madagascar", 410 + "native_name": "Madagascar" 411 + }, 412 + { 413 + "iso_3166_1": "MK", 414 + "english_name": "Macedonia", 415 + "native_name": "Macedonia" 416 + }, 417 + { 418 + "iso_3166_1": "ML", 419 + "english_name": "Mali", 420 + "native_name": "Mali" 421 + }, 422 + { 423 + "iso_3166_1": "MT", 424 + "english_name": "Malta", 425 + "native_name": "Malta" 426 + }, 427 + { 428 + "iso_3166_1": "MU", 429 + "english_name": "Mauritius", 430 + "native_name": "Mauritius" 431 + }, 432 + { 433 + "iso_3166_1": "MW", 434 + "english_name": "Malawi", 435 + "native_name": "Malawi" 436 + }, 437 + { 438 + "iso_3166_1": "MX", 439 + "english_name": "Mexico", 440 + "native_name": "Mexico" 441 + }, 442 + { 443 + "iso_3166_1": "MY", 444 + "english_name": "Malaysia", 445 + "native_name": "Malaysia" 446 + }, 447 + { 448 + "iso_3166_1": "MZ", 449 + "english_name": "Mozambique", 450 + "native_name": "Mozambique" 451 + }, 452 + { 453 + "iso_3166_1": "NE", 454 + "english_name": "Niger", 455 + "native_name": "Niger" 456 + }, 457 + { 458 + "iso_3166_1": "NG", 459 + "english_name": "Nigeria", 460 + "native_name": "Nigeria" 461 + }, 462 + { 463 + "iso_3166_1": "NI", 464 + "english_name": "Nicaragua", 465 + "native_name": "Nicaragua" 466 + }, 467 + { 468 + "iso_3166_1": "NL", 469 + "english_name": "Netherlands", 470 + "native_name": "Netherlands" 471 + }, 472 + { 473 + "iso_3166_1": "NO", 474 + "english_name": "Norway", 475 + "native_name": "Norway" 476 + }, 477 + { 478 + "iso_3166_1": "NZ", 479 + "english_name": "New Zealand", 480 + "native_name": "New Zealand" 481 + }, 482 + { 483 + "iso_3166_1": "OM", 484 + "english_name": "Oman", 485 + "native_name": "Oman" 486 + }, 487 + { 488 + "iso_3166_1": "PA", 489 + "english_name": "Panama", 490 + "native_name": "Panama" 491 + }, 492 + { 493 + "iso_3166_1": "PE", 494 + "english_name": "Peru", 495 + "native_name": "Peru" 496 + }, 497 + { 498 + "iso_3166_1": "PF", 499 + "english_name": "French Polynesia", 500 + "native_name": "French Polynesia" 501 + }, 502 + { 503 + "iso_3166_1": "PG", 504 + "english_name": "Papua New Guinea", 505 + "native_name": "Papua New Guinea" 506 + }, 507 + { 508 + "iso_3166_1": "PH", 509 + "english_name": "Philippines", 510 + "native_name": "Philippines" 511 + }, 512 + { 513 + "iso_3166_1": "PK", 514 + "english_name": "Pakistan", 515 + "native_name": "Pakistan" 516 + }, 517 + { 518 + "iso_3166_1": "PL", 519 + "english_name": "Poland", 520 + "native_name": "Poland" 521 + }, 522 + { 523 + "iso_3166_1": "PS", 524 + "english_name": "Palestinian Territory", 525 + "native_name": "Palestinian Territories" 526 + }, 527 + { 528 + "iso_3166_1": "PT", 529 + "english_name": "Portugal", 530 + "native_name": "Portugal" 531 + }, 532 + { 533 + "iso_3166_1": "PY", 534 + "english_name": "Paraguay", 535 + "native_name": "Paraguay" 536 + }, 537 + { 538 + "iso_3166_1": "QA", 539 + "english_name": "Qatar", 540 + "native_name": "Qatar" 541 + }, 542 + { 543 + "iso_3166_1": "RO", 544 + "english_name": "Romania", 545 + "native_name": "Romania" 546 + }, 547 + { 548 + "iso_3166_1": "RS", 549 + "english_name": "Serbia", 550 + "native_name": "Serbia" 551 + }, 552 + { 553 + "iso_3166_1": "RU", 554 + "english_name": "Russia", 555 + "native_name": "Russia" 556 + }, 557 + { 558 + "iso_3166_1": "SA", 559 + "english_name": "Saudi Arabia", 560 + "native_name": "Saudi Arabia" 561 + }, 562 + { 563 + "iso_3166_1": "SC", 564 + "english_name": "Seychelles", 565 + "native_name": "Seychelles" 566 + }, 567 + { 568 + "iso_3166_1": "SE", 569 + "english_name": "Sweden", 570 + "native_name": "Sweden" 571 + }, 572 + { 573 + "iso_3166_1": "SG", 574 + "english_name": "Singapore", 575 + "native_name": "Singapore" 576 + }, 577 + { 578 + "iso_3166_1": "SI", 579 + "english_name": "Slovenia", 580 + "native_name": "Slovenia" 581 + }, 582 + { 583 + "iso_3166_1": "SK", 584 + "english_name": "Slovakia", 585 + "native_name": "Slovakia" 586 + }, 587 + { 588 + "iso_3166_1": "SM", 589 + "english_name": "San Marino", 590 + "native_name": "San Marino" 591 + }, 592 + { 593 + "iso_3166_1": "SN", 594 + "english_name": "Senegal", 595 + "native_name": "Senegal" 596 + }, 597 + { 598 + "iso_3166_1": "SV", 599 + "english_name": "El Salvador", 600 + "native_name": "El Salvador" 601 + }, 602 + { 603 + "iso_3166_1": "TC", 604 + "english_name": "Turks and Caicos Islands", 605 + "native_name": "Turks & Caicos Islands" 606 + }, 607 + { 608 + "iso_3166_1": "TD", 609 + "english_name": "Chad", 610 + "native_name": "Chad" 611 + }, 612 + { 613 + "iso_3166_1": "TH", 614 + "english_name": "Thailand", 615 + "native_name": "Thailand" 616 + }, 617 + { 618 + "iso_3166_1": "TN", 619 + "english_name": "Tunisia", 620 + "native_name": "Tunisia" 621 + }, 622 + { 623 + "iso_3166_1": "TR", 624 + "english_name": "Turkey", 625 + "native_name": "Turkey" 626 + }, 627 + { 628 + "iso_3166_1": "TT", 629 + "english_name": "Trinidad and Tobago", 630 + "native_name": "Trinidad & Tobago" 631 + }, 632 + { 633 + "iso_3166_1": "TW", 634 + "english_name": "Taiwan", 635 + "native_name": "Taiwan" 636 + }, 637 + { 638 + "iso_3166_1": "TZ", 639 + "english_name": "Tanzania", 640 + "native_name": "Tanzania" 641 + }, 642 + { 643 + "iso_3166_1": "UA", 644 + "english_name": "Ukraine", 645 + "native_name": "Ukraine" 646 + }, 647 + { 648 + "iso_3166_1": "UG", 649 + "english_name": "Uganda", 650 + "native_name": "Uganda" 651 + }, 652 + { 653 + "iso_3166_1": "US", 654 + "english_name": "United States of America", 655 + "native_name": "USA" 656 + }, 657 + { 658 + "iso_3166_1": "UY", 659 + "english_name": "Uruguay", 660 + "native_name": "Uruguay" 661 + }, 662 + { 663 + "iso_3166_1": "VA", 664 + "english_name": "Holy See", 665 + "native_name": "Vatican City" 666 + }, 667 + { 668 + "iso_3166_1": "VE", 669 + "english_name": "Venezuela", 670 + "native_name": "Venezuela" 671 + }, 672 + { 673 + "iso_3166_1": "XK", 674 + "english_name": "Kosovo", 675 + "native_name": "Kosovo" 676 + }, 677 + { 678 + "iso_3166_1": "YE", 679 + "english_name": "Yemen", 680 + "native_name": "Yemen" 681 + }, 682 + { 683 + "iso_3166_1": "ZA", 684 + "english_name": "South Africa", 685 + "native_name": "South Africa" 686 + }, 687 + { 688 + "iso_3166_1": "ZM", 689 + "english_name": "Zambia", 690 + "native_name": "Zambia" 691 + }, 692 + { 693 + "iso_3166_1": "ZW", 694 + "english_name": "Zimbabwe", 695 + "native_name": "Zimbabwe" 696 + } 697 + ]
+63 -77
src/routes/[kind]/[id]/+page.svelte
··· 11 11 </script> 12 12 13 13 <svelte:head> 14 - <title>{data.result.original_title ?? data.result.original_name ?? ''} | skywatched</title> 14 + <title>{data.result.title ?? data.result.name ?? ''} | skywatched</title> 15 15 </svelte:head> 16 + 17 + {#snippet buttons()} 18 + {#if data.user && !watchedItems.hasRated(data.result)} 19 + <button 20 + class={cn( 21 + '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 ', 22 + watchedItems.hasRated(data.result) 23 + ? 'bg-green-500/10 text-green-400 hover:bg-green-500/20' 24 + : '' 25 + )} 26 + onclick={() => { 27 + rateMovieModal.show({ 28 + movieId: data.result.movieId, 29 + showId: data.result.showId, 30 + kind: data.result.movieId ? 'movie' : 'tv', 31 + name: data.result.original_title ?? data.result.original_name, 32 + posterPath: data.result.poster_path, 33 + currentRating: watchedItems.getRating(data.result)?.rating ?? 0, 34 + currentReview: watchedItems.getRating(data.result)?.ratingText ?? '' 35 + }); 36 + }} 37 + > 38 + rate {data.kind === 'movie' ? 'movie' : 'show'} 39 + </button> 40 + {/if} 41 + {#if data.trailer} 42 + <button 43 + onclick={() => videoPlayer.show(data.trailer ?? '')} 44 + type="button" 45 + 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" 46 + > 47 + <svg 48 + xmlns="http://www.w3.org/2000/svg" 49 + viewBox="0 0 24 24" 50 + fill="currentColor" 51 + class="-ml-0.5 size-5" 52 + > 53 + <path 54 + 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" 55 + /> 56 + </svg> 57 + 58 + Trailer 59 + </button> 60 + {/if} 61 + {/snippet} 16 62 17 63 <img 18 64 src="https://image.tmdb.org/t/p/w780{data.result.backdrop_path}" ··· 30 76 /> 31 77 <div class="flex flex-col gap-4"> 32 78 <div class="max-w-xl text-2xl font-semibold text-white sm:text-4xl"> 33 - {data.result.original_title ?? data.result.original_name} 79 + {data.result.title ?? data.result.name} 34 80 </div> 35 81 36 - {#if data.watchProviders.US?.flatrate} 82 + {#if data.settings?.streaming_region?.code && data.watchProviders[data.settings.streaming_region.code]?.flatrate} 37 83 <div class="mt-2 text-sm text-white sm:mt-4"> 38 84 <div class="mb-2 flex flex-wrap gap-4 text-xs font-medium"> 39 85 stream on ··· 47 93 </a></span 48 94 > 49 95 </div> 50 - <a href={data.watchProviders.US.link} target="_blank" class="flex flex-wrap gap-2"> 51 - {#each data.watchProviders.US.flatrate as provider} 96 + <a 97 + href={data.watchProviders[data.settings.streaming_region.code].link} 98 + target="_blank" 99 + class="flex flex-wrap gap-2" 100 + > 101 + {#each data.watchProviders[data.settings.streaming_region.code].flatrate as provider} 52 102 <img 53 103 src="https://image.tmdb.org/t/p/w500{provider.logo_path}" 54 104 alt={provider.provider_name} ··· 58 108 </a> 59 109 </div> 60 110 {/if} 61 - 62 - {#if data.trailer} 63 - <button 64 - onclick={() => videoPlayer.show(data.trailer ?? '')} 65 - type="button" 66 - class="hidden 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 sm:inline-flex" 67 - > 68 - <svg 69 - xmlns="http://www.w3.org/2000/svg" 70 - viewBox="0 0 24 24" 71 - fill="currentColor" 72 - class="-ml-0.5 size-5" 73 - > 74 - <path 75 - 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" 76 - /> 77 - </svg> 78 - 79 - Trailer 80 - </button> 81 - {/if} 111 + <div class="hidden gap-2 sm:flex"> 112 + {@render buttons()} 113 + </div> 82 114 </div> 83 115 </div> 84 116 85 - <div class="px-4 py-8 text-sm text-white"> 86 - {#if data.trailer} 87 - <button 88 - onclick={() => videoPlayer.show(data.trailer ?? '')} 89 - type="button" 90 - class="mb-4 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 sm:hidden" 91 - > 92 - <svg 93 - xmlns="http://www.w3.org/2000/svg" 94 - viewBox="0 0 24 24" 95 - fill="currentColor" 96 - class="-ml-0.5 size-5" 97 - > 98 - <path 99 - 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" 100 - /> 101 - </svg> 102 - 103 - Trailer 104 - </button> 105 - {/if} 106 - 107 - {#if data.user} 108 - <div class="flex gap-4"> 109 - {#if !watchedItems.hasRated(data.result)} 110 - <button 111 - class={cn( 112 - '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 ', 113 - watchedItems.hasRated(data.result) 114 - ? 'bg-green-500/10 text-green-400 hover:bg-green-500/20' 115 - : '' 116 - )} 117 - onclick={() => { 118 - rateMovieModal.selectedItem = { 119 - movieId: data.result.movieId, 120 - showId: data.result.showId, 121 - kind: data.result.movieId ? 'movie' : 'tv', 122 - name: data.result.original_title ?? data.result.original_name, 123 - currentRating: watchedItems.getRating(data.result)?.rating ?? 0, 124 - currentReview: watchedItems.getRating(data.result)?.ratingText ?? '' 125 - }; 126 - 127 - rateMovieModal.showModal = true; 128 - }} 129 - > 130 - rate {data.kind === 'movie' ? 'movie' : 'show'} 131 - </button> 132 - {/if} 133 - </div> 134 - {/if} 135 - <div class="my-8 max-w-2xl text-sm text-white"> 117 + <div class="px-4 pb-8 pt-4 text-sm text-white"> 118 + <div class="mb-4 flex gap-2 sm:hidden"> 119 + {@render buttons()} 120 + </div> 121 + <div class="mb-4 max-w-2xl text-sm text-white"> 136 122 <div class="mb-2 text-lg font-semibold">overview</div> 137 123 {data.result.overview} 138 124 </div>
+17 -1
src/routes/api/rate/+server.ts
··· 18 18 19 19 const rkey = TID.nextStr(); 20 20 21 - const record = { 21 + const record: { 22 + repo: string; 23 + collection: string; 24 + rkey: string; 25 + record: { 26 + item: { 27 + ref: string; 28 + value: string; 29 + }; 30 + rating: { value: number; createdAt: string }; 31 + note?: { 32 + value: string; 33 + createdAt: string; 34 + updatedAt: string; 35 + }; 36 + }; 37 + } = { 22 38 repo: did, 23 39 collection: REL_COLLECTION, 24 40 rkey,
+14
src/routes/api/search/+server.ts
··· 1 + import { searchMulti } from '$lib/server/movies'; 2 + import { error, json, type RequestHandler } from '@sveltejs/kit'; 3 + 4 + export const GET: RequestHandler = async ({ request }) => { 5 + const q = new URL(request.url).searchParams.get('q'); 6 + 7 + if (!q) { 8 + return error(400, 'Query is required'); 9 + } 10 + 11 + const results = await searchMulti(q); 12 + 13 + return json(results); 14 + };
+18
src/routes/oauth/callback/+server.ts
··· 4 4 import { NYX_PASSWORD } from '$env/static/private'; 5 5 import { encryptString } from '$lib/server/crypts'; 6 6 import { decodeBase64, encodeBase64urlNoPadding } from '@oslojs/encoding'; 7 + import { Agent } from '@atproto/api'; 8 + import type { OAuthSession } from '@atproto/oauth-client-node'; 7 9 8 10 export async function GET({ request, cookies }: RequestEvent) { 9 11 const params = new URLSearchParams(request.url.split('?')[1]); 12 + 13 + let mySession: OAuthSession; 10 14 try { 11 15 const { session } = await atclient.callback(params); 16 + mySession = session; 12 17 const key = decodeBase64(NYX_PASSWORD); 13 18 const encrypted = await encryptString(key, session.did); 14 19 const encoded = encodeBase64urlNoPadding(encrypted); ··· 21 26 } catch (err) { 22 27 console.error(err); 23 28 error(500, { message: (err as Error).message }); 29 + } 30 + 31 + try { 32 + const agent = new Agent(mySession); 33 + 34 + await agent.com.atproto.repo.getRecord({ 35 + repo: mySession.did, 36 + collection: 'app.skywatched.settings', 37 + rkey: 'self' 38 + }); 39 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 40 + } catch (_e) { 41 + redirect(301, '/onboarding'); 24 42 } 25 43 26 44 redirect(301, '/');
+18 -1
src/routes/review/[uri]/+page.svelte
··· 6 6 let { data } = $props(); 7 7 </script> 8 8 9 - <Container> 9 + {#if data.record.record.metadata?.backdrop_path} 10 + <img 11 + src="https://image.tmdb.org/t/p/w780{data.record.record.metadata.backdrop_path}" 12 + alt="" 13 + class="fixed h-full w-full object-cover object-center opacity-20" 14 + /> 15 + {/if} 16 + <div class="fixed inset-0 h-full w-full bg-black/50"></div> 17 + 18 + <Container class="z-10 flex h-screen flex-col items-center justify-center"> 10 19 <ReviewCard data={data.record} /> 20 + 21 + <div 22 + class="z-10 rounded-lg border border-base-700 bg-base-50/5 px-3 py-2 text-xs font-medium text-base-200 transition-all duration-100 hover:bg-base-50/10" 23 + > 24 + <a href={`/user/${data.record.author.handle}`}> 25 + all reviews by {data.record.author.displayName || data.record.author.handle} 26 + </a> 27 + </div> 11 28 </Container>
+1 -1
src/routes/user/[handle]/+page.svelte
··· 14 14 <Profile profile={data.profile} /> 15 15 16 16 {#if data.items.length > 0} 17 - <ReviewList reviews={data.items} class="mt-8" /> 17 + <ReviewList reviews={data.items} class="mx-auto mt-8 max-w-2xl" /> 18 18 {:else} 19 19 <p class="py-8 text-center text-base-500">No movies or tv shows rated yet.</p> 20 20 {/if}