[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 #28 from flo-bit/v3

v3

author
Florian
committer
GitHub
date (Dec 12, 2024, 3:06 AM +0100) commit e41556f9 parent d87666b8
+1109 -196
+5 -1
.env.example
··· 5 5 DATABASE_URL="file:local.db" 6 6 7 7 # API key for https://www.themoviedb.org/ 8 - TMDB_API_KEY="" 8 + TMDB_API_KEY="" 9 + 10 + # A random key for encrypting the session 11 + # on unix systems you can generate one with `openssl rand -base64 32` 12 + NYX_PASSWORD=""
+351 -32
package-lock.json
··· 8 8 "name": "nyx", 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 + "@atproto/api": "^0.13.20", 12 + "@atproto/oauth-client-node": "^0.2.4", 11 13 "@libsql/client": "^0.9.0", 12 14 "@node-rs/argon2": "^1.1.0", 13 15 "@oslojs/crypto": "^1.0.1", ··· 16 18 "dotenv": "^16.4.5", 17 19 "drizzle-orm": "^0.33.0", 18 20 "plyr": "^3.7.8", 21 + "svelte-sonner": "^0.3.28", 19 22 "tailwind-merge": "^2.5.4" 20 23 }, 21 24 "devDependencies": { ··· 59 62 "version": "2.3.0", 60 63 "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", 61 64 "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", 62 - "dev": true, 63 65 "dependencies": { 64 66 "@jridgewell/gen-mapping": "^0.3.5", 65 67 "@jridgewell/trace-mapping": "^0.3.24" ··· 68 70 "node": ">=6.0.0" 69 71 } 70 72 }, 73 + "node_modules/@atproto-labs/did-resolver": { 74 + "version": "0.1.6", 75 + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.1.6.tgz", 76 + "integrity": "sha512-qddGpcjKj9SUMlZW1d+/dY/03CDVcmOAlAozXEXsU2H5OT1vFAdMmOp0VbwK0y99RH3DvAQtyQKiRzoPFqp8rA==", 77 + "dependencies": { 78 + "@atproto-labs/fetch": "0.1.2", 79 + "@atproto-labs/pipe": "0.1.0", 80 + "@atproto-labs/simple-store": "0.1.1", 81 + "@atproto-labs/simple-store-memory": "0.1.1", 82 + "@atproto/did": "0.1.3", 83 + "zod": "^3.23.8" 84 + } 85 + }, 86 + "node_modules/@atproto-labs/fetch": { 87 + "version": "0.1.2", 88 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.1.2.tgz", 89 + "integrity": "sha512-7mQQIRtVenqtdBQKCqoLjyAhPS2aA56EGEjyz5zB3sramM3qkrvzyusr55GAzGDS0tvB6cy9cDEtSLmfK7LUnA==", 90 + "dependencies": { 91 + "@atproto-labs/pipe": "0.1.0" 92 + }, 93 + "optionalDependencies": { 94 + "zod": "^3.23.8" 95 + } 96 + }, 97 + "node_modules/@atproto-labs/fetch-node": { 98 + "version": "0.1.4", 99 + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.1.4.tgz", 100 + "integrity": "sha512-hwYx0XpgIl2zydRy13DtWvywruuHk1EX+yCjqjgUIezUm8fi35ZN4QvR6INEm0MpN2MD/kQsImPbd8ZftzZ3zw==", 101 + "dependencies": { 102 + "@atproto-labs/fetch": "0.1.2", 103 + "@atproto-labs/pipe": "0.1.0", 104 + "ipaddr.js": "^2.1.0", 105 + "psl": "^1.9.0", 106 + "undici": "^6.14.1" 107 + } 108 + }, 109 + "node_modules/@atproto-labs/handle-resolver": { 110 + "version": "0.1.4", 111 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.1.4.tgz", 112 + "integrity": "sha512-tnGUD2mQ6c8xHs3eeVJgwYqM3FHoTZZbOcOGKqO1A5cuIG+gElwEhpWwpwX5LI7FF4J8eS9BOHLl3NFS7Q8QXg==", 113 + "dependencies": { 114 + "@atproto-labs/simple-store": "0.1.1", 115 + "@atproto-labs/simple-store-memory": "0.1.1", 116 + "@atproto/did": "0.1.3", 117 + "zod": "^3.23.8" 118 + } 119 + }, 120 + "node_modules/@atproto-labs/handle-resolver-node": { 121 + "version": "0.1.8", 122 + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver-node/-/handle-resolver-node-0.1.8.tgz", 123 + "integrity": "sha512-AlH7qRtmhZFRCcv1HK9OYiZpTFGcX39zjzzANq42zVLlfqoJr3ugnv7mAXGHE8woVguKtbypHnrVCDceoBAs2w==", 124 + "dependencies": { 125 + "@atproto-labs/fetch-node": "0.1.4", 126 + "@atproto-labs/handle-resolver": "0.1.4", 127 + "@atproto/did": "0.1.3" 128 + } 129 + }, 130 + "node_modules/@atproto-labs/identity-resolver": { 131 + "version": "0.1.7", 132 + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.1.7.tgz", 133 + "integrity": "sha512-aRmY0cp+aFDgxAD62jjCPUDJMqryuXmt0hK9ls8LHeSzszr58BFDwybLaW6Izz2KISQlzu75Ia0c6uRymdmcYA==", 134 + "dependencies": { 135 + "@atproto-labs/did-resolver": "0.1.6", 136 + "@atproto-labs/handle-resolver": "0.1.4", 137 + "@atproto/syntax": "0.3.1" 138 + } 139 + }, 140 + "node_modules/@atproto-labs/pipe": { 141 + "version": "0.1.0", 142 + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.1.0.tgz", 143 + "integrity": "sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w==" 144 + }, 145 + "node_modules/@atproto-labs/simple-store": { 146 + "version": "0.1.1", 147 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz", 148 + "integrity": "sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==" 149 + }, 150 + "node_modules/@atproto-labs/simple-store-memory": { 151 + "version": "0.1.1", 152 + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.1.tgz", 153 + "integrity": "sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==", 154 + "dependencies": { 155 + "@atproto-labs/simple-store": "0.1.1", 156 + "lru-cache": "^10.2.0" 157 + } 158 + }, 159 + "node_modules/@atproto/api": { 160 + "version": "0.13.20", 161 + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.20.tgz", 162 + "integrity": "sha512-z/+CvG6BEttRHf856tKSe1AeUQNfrobRJldaHAthGmFk7O3wLZQyfcI9DUmBJQ9+4wAt0dZwvKWVGLZOV9eLHA==", 163 + "dependencies": { 164 + "@atproto/common-web": "^0.3.1", 165 + "@atproto/lexicon": "^0.4.4", 166 + "@atproto/syntax": "^0.3.1", 167 + "@atproto/xrpc": "^0.6.5", 168 + "await-lock": "^2.2.2", 169 + "multiformats": "^9.9.0", 170 + "tlds": "^1.234.0", 171 + "zod": "^3.23.8" 172 + } 173 + }, 174 + "node_modules/@atproto/common-web": { 175 + "version": "0.3.1", 176 + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.3.1.tgz", 177 + "integrity": "sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q==", 178 + "dependencies": { 179 + "graphemer": "^1.4.0", 180 + "multiformats": "^9.9.0", 181 + "uint8arrays": "3.0.0", 182 + "zod": "^3.23.8" 183 + } 184 + }, 185 + "node_modules/@atproto/did": { 186 + "version": "0.1.3", 187 + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.1.3.tgz", 188 + "integrity": "sha512-ULD8Gw/KRRwLFZ2Z2L4DjmdOMrg8IYYlcjdSc+GQ2/QJSVnD2zaJJVTLd3vls121wGt/583rNaiZTT2DpBze4w==", 189 + "dependencies": { 190 + "zod": "^3.23.8" 191 + } 192 + }, 193 + "node_modules/@atproto/jwk": { 194 + "version": "0.1.1", 195 + "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.1.1.tgz", 196 + "integrity": "sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==", 197 + "dependencies": { 198 + "multiformats": "^9.9.0", 199 + "zod": "^3.23.8" 200 + } 201 + }, 202 + "node_modules/@atproto/jwk-jose": { 203 + "version": "0.1.2", 204 + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz", 205 + "integrity": "sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==", 206 + "dependencies": { 207 + "@atproto/jwk": "0.1.1", 208 + "jose": "^5.2.0" 209 + } 210 + }, 211 + "node_modules/@atproto/jwk-webcrypto": { 212 + "version": "0.1.2", 213 + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.1.2.tgz", 214 + "integrity": "sha512-vTBUbUZXh0GI+6KJiPGukmI4BQEHFAij8fJJ4WnReF/hefAs3ISZtrWZHGBebz+q2EcExYlnhhlmxvDzV7veGw==", 215 + "dependencies": { 216 + "@atproto/jwk": "0.1.1", 217 + "@atproto/jwk-jose": "0.1.2" 218 + } 219 + }, 220 + "node_modules/@atproto/lexicon": { 221 + "version": "0.4.4", 222 + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.4.tgz", 223 + "integrity": "sha512-QFEmr3rpj/RoAmfX9ALU/asBG/rsVtQZnw+9nOB1/AuIwoxXd+ZyndR6lVUc2+DL4GEjl6W2yvBru5xbQIZWyA==", 224 + "dependencies": { 225 + "@atproto/common-web": "^0.3.1", 226 + "@atproto/syntax": "^0.3.1", 227 + "iso-datestring-validator": "^2.2.2", 228 + "multiformats": "^9.9.0", 229 + "zod": "^3.23.8" 230 + } 231 + }, 232 + "node_modules/@atproto/oauth-client": { 233 + "version": "0.3.4", 234 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.3.4.tgz", 235 + "integrity": "sha512-dPvMstfzNvcbyWcQZBq56WLnJp/OwPT+OjJ2RsMfl8jXVXpgPdviv/k/ttt5TfOmkbc1G7/2JOIFaS27BvsXyw==", 236 + "dependencies": { 237 + "@atproto-labs/did-resolver": "0.1.6", 238 + "@atproto-labs/fetch": "0.1.2", 239 + "@atproto-labs/handle-resolver": "0.1.4", 240 + "@atproto-labs/identity-resolver": "0.1.7", 241 + "@atproto-labs/simple-store": "0.1.1", 242 + "@atproto-labs/simple-store-memory": "0.1.1", 243 + "@atproto/did": "0.1.3", 244 + "@atproto/jwk": "0.1.1", 245 + "@atproto/oauth-types": "0.2.1", 246 + "@atproto/xrpc": "0.6.5", 247 + "multiformats": "^9.9.0", 248 + "zod": "^3.23.8" 249 + } 250 + }, 251 + "node_modules/@atproto/oauth-client-node": { 252 + "version": "0.2.4", 253 + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-node/-/oauth-client-node-0.2.4.tgz", 254 + "integrity": "sha512-Uq5VG0nrFh9CPbfhjT4OEmcPiKfFSrvfoB1PM5lCkZoSiO/+trHPmzaxA+sszMvtd53pbYyeBgqn+gP0EF6jYw==", 255 + "dependencies": { 256 + "@atproto-labs/did-resolver": "0.1.6", 257 + "@atproto-labs/handle-resolver-node": "0.1.8", 258 + "@atproto-labs/simple-store": "0.1.1", 259 + "@atproto/did": "0.1.3", 260 + "@atproto/jwk": "0.1.1", 261 + "@atproto/jwk-jose": "0.1.2", 262 + "@atproto/jwk-webcrypto": "0.1.2", 263 + "@atproto/oauth-client": "0.3.4", 264 + "@atproto/oauth-types": "0.2.1" 265 + } 266 + }, 267 + "node_modules/@atproto/oauth-types": { 268 + "version": "0.2.1", 269 + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.2.1.tgz", 270 + "integrity": "sha512-hDisUXzcq5KU1HMuCYZ8Kcz7BePl7V11bFjjgZvND3mdSphiyBpJ8MCNn3QzAa6cXpFo0w9PDcYMAlCCRZHdVw==", 271 + "dependencies": { 272 + "@atproto/jwk": "0.1.1", 273 + "zod": "^3.23.8" 274 + } 275 + }, 276 + "node_modules/@atproto/syntax": { 277 + "version": "0.3.1", 278 + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.1.tgz", 279 + "integrity": "sha512-fzW0Mg1QUOVCWUD3RgEsDt6d1OZ6DdFmbKcDdbzUfh0t4rhtRAC05KbZYmxuMPWDAiJ4BbbQ5dkAc/mNypMXkw==" 280 + }, 281 + "node_modules/@atproto/xrpc": { 282 + "version": "0.6.5", 283 + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.5.tgz", 284 + "integrity": "sha512-t6u8iPEVbWge5RhzKZDahSzNDYIAxUtop6Q/X/apAZY1rgreVU0/1sSvvRoRFH19d3UIKjYdLuwFqMi9w8nY3Q==", 285 + "dependencies": { 286 + "@atproto/lexicon": "^0.4.4", 287 + "zod": "^3.23.8" 288 + } 289 + }, 71 290 "node_modules/@emnapi/core": { 72 291 "version": "1.3.1", 73 292 "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", ··· 1084 1303 "version": "0.3.5", 1085 1304 "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", 1086 1305 "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", 1087 - "dev": true, 1088 1306 "dependencies": { 1089 1307 "@jridgewell/set-array": "^1.2.1", 1090 1308 "@jridgewell/sourcemap-codec": "^1.4.10", ··· 1098 1316 "version": "3.1.2", 1099 1317 "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 1100 1318 "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 1101 - "dev": true, 1102 1319 "engines": { 1103 1320 "node": ">=6.0.0" 1104 1321 } ··· 1107 1324 "version": "1.2.1", 1108 1325 "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", 1109 1326 "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", 1110 - "dev": true, 1111 1327 "engines": { 1112 1328 "node": ">=6.0.0" 1113 1329 } ··· 1115 1331 "node_modules/@jridgewell/sourcemap-codec": { 1116 1332 "version": "1.5.0", 1117 1333 "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", 1118 - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", 1119 - "dev": true 1334 + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" 1120 1335 }, 1121 1336 "node_modules/@jridgewell/trace-mapping": { 1122 1337 "version": "0.3.25", 1123 1338 "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", 1124 1339 "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", 1125 - "dev": true, 1126 1340 "dependencies": { 1127 1341 "@jridgewell/resolve-uri": "^3.1.0", 1128 1342 "@jridgewell/sourcemap-codec": "^1.4.14" ··· 1968 2182 "node_modules/@types/estree": { 1969 2183 "version": "1.0.6", 1970 2184 "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", 1971 - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", 1972 - "dev": true 2185 + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==" 1973 2186 }, 1974 2187 "node_modules/@types/json-schema": { 1975 2188 "version": "7.0.15", ··· 2211 2424 "version": "8.14.0", 2212 2425 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", 2213 2426 "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", 2214 - "dev": true, 2215 2427 "bin": { 2216 2428 "acorn": "bin/acorn" 2217 2429 }, ··· 2232 2444 "version": "1.4.13", 2233 2445 "resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz", 2234 2446 "integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==", 2235 - "dev": true, 2236 2447 "peerDependencies": { 2237 2448 "acorn": ">=8.9.0" 2238 2449 } ··· 2299 2510 "node": ">= 8" 2300 2511 } 2301 2512 }, 2513 + "node_modules/anymatch/node_modules/picomatch": { 2514 + "version": "2.3.1", 2515 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 2516 + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 2517 + "dev": true, 2518 + "engines": { 2519 + "node": ">=8.6" 2520 + }, 2521 + "funding": { 2522 + "url": "https://github.com/sponsors/jonschlinkert" 2523 + } 2524 + }, 2302 2525 "node_modules/arg": { 2303 2526 "version": "5.0.2", 2304 2527 "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", ··· 2315 2538 "version": "5.3.2", 2316 2539 "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", 2317 2540 "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", 2318 - "dev": true, 2319 2541 "engines": { 2320 2542 "node": ">= 0.4" 2321 2543 } ··· 2357 2579 "postcss": "^8.1.0" 2358 2580 } 2359 2581 }, 2582 + "node_modules/await-lock": { 2583 + "version": "2.2.2", 2584 + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", 2585 + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 2586 + }, 2360 2587 "node_modules/axobject-query": { 2361 2588 "version": "4.1.0", 2362 2589 "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", 2363 2590 "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", 2364 - "dev": true, 2365 2591 "engines": { 2366 2592 "node": ">= 0.4" 2367 2593 } ··· 3068 3294 "node_modules/esm-env": { 3069 3295 "version": "1.1.4", 3070 3296 "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.1.4.tgz", 3071 - "integrity": "sha512-oO82nKPHKkzIj/hbtuDYy/JHqBHFlMIW36SDiPCVsj87ntDLcWN+sJ1erdVryd4NxODacFTsdrIE3b7IamqbOg==", 3072 - "dev": true 3297 + "integrity": "sha512-oO82nKPHKkzIj/hbtuDYy/JHqBHFlMIW36SDiPCVsj87ntDLcWN+sJ1erdVryd4NxODacFTsdrIE3b7IamqbOg==" 3073 3298 }, 3074 3299 "node_modules/espree": { 3075 3300 "version": "10.3.0", ··· 3104 3329 "version": "1.2.2", 3105 3330 "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.2.2.tgz", 3106 3331 "integrity": "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==", 3107 - "dev": true, 3108 3332 "dependencies": { 3109 3333 "@jridgewell/sourcemap-codec": "^1.4.15", 3110 3334 "@types/estree": "^1.0.1" ··· 3448 3672 "node_modules/graphemer": { 3449 3673 "version": "1.4.0", 3450 3674 "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", 3451 - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", 3452 - "dev": true 3675 + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 3453 3676 }, 3454 3677 "node_modules/has-flag": { 3455 3678 "version": "4.0.0", ··· 3516 3739 "node": ">=0.8.19" 3517 3740 } 3518 3741 }, 3742 + "node_modules/ipaddr.js": { 3743 + "version": "2.2.0", 3744 + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", 3745 + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", 3746 + "engines": { 3747 + "node": ">= 10" 3748 + } 3749 + }, 3519 3750 "node_modules/is-binary-path": { 3520 3751 "version": "2.1.0", 3521 3752 "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", ··· 3586 3817 "version": "3.0.3", 3587 3818 "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", 3588 3819 "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", 3589 - "dev": true, 3590 3820 "dependencies": { 3591 3821 "@types/estree": "^1.0.6" 3592 3822 } ··· 3596 3826 "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 3597 3827 "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 3598 3828 "dev": true 3829 + }, 3830 + "node_modules/iso-datestring-validator": { 3831 + "version": "2.2.2", 3832 + "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", 3833 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 3599 3834 }, 3600 3835 "node_modules/jackspeak": { 3601 3836 "version": "3.4.3", ··· 3621 3856 "jiti": "bin/jiti.js" 3622 3857 } 3623 3858 }, 3859 + "node_modules/jose": { 3860 + "version": "5.9.6", 3861 + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", 3862 + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", 3863 + "funding": { 3864 + "url": "https://github.com/sponsors/panva" 3865 + } 3866 + }, 3624 3867 "node_modules/js-base64": { 3625 3868 "version": "3.7.7", 3626 3869 "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", ··· 3744 3987 "node_modules/locate-character": { 3745 3988 "version": "3.0.0", 3746 3989 "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", 3747 - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", 3748 - "dev": true 3990 + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" 3749 3991 }, 3750 3992 "node_modules/locate-path": { 3751 3993 "version": "6.0.0", ··· 3783 4025 "node_modules/lru-cache": { 3784 4026 "version": "10.4.3", 3785 4027 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 3786 - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 3787 - "dev": true 4028 + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" 3788 4029 }, 3789 4030 "node_modules/magic-string": { 3790 4031 "version": "0.30.13", 3791 4032 "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.13.tgz", 3792 4033 "integrity": "sha512-8rYBO+MsWkgjDSOvLomYnzhdwEG51olQ4zL5KXnNJWV5MNmrb4rTZdrtkhxjnD/QyZUqR/Z/XDsUs/4ej2nx0g==", 3793 - "dev": true, 3794 4034 "dependencies": { 3795 4035 "@jridgewell/sourcemap-codec": "^1.5.0" 3796 4036 } ··· 3817 4057 "node": ">=8.6" 3818 4058 } 3819 4059 }, 4060 + "node_modules/micromatch/node_modules/picomatch": { 4061 + "version": "2.3.1", 4062 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 4063 + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 4064 + "dev": true, 4065 + "engines": { 4066 + "node": ">=8.6" 4067 + }, 4068 + "funding": { 4069 + "url": "https://github.com/sponsors/jonschlinkert" 4070 + } 4071 + }, 3820 4072 "node_modules/mini-svg-data-uri": { 3821 4073 "version": "1.4.4", 3822 4074 "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", ··· 3870 4122 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 3871 4123 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 3872 4124 "dev": true 4125 + }, 4126 + "node_modules/multiformats": { 4127 + "version": "9.9.0", 4128 + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz", 4129 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" 3873 4130 }, 3874 4131 "node_modules/mz": { 3875 4132 "version": "2.7.0", ··· 4095 4352 "dev": true 4096 4353 }, 4097 4354 "node_modules/picomatch": { 4098 - "version": "2.3.1", 4099 - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 4100 - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 4355 + "version": "4.0.2", 4356 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", 4357 + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", 4101 4358 "dev": true, 4359 + "optional": true, 4360 + "peer": true, 4102 4361 "engines": { 4103 - "node": ">=8.6" 4362 + "node": ">=12" 4104 4363 }, 4105 4364 "funding": { 4106 4365 "url": "https://github.com/sponsors/jonschlinkert" ··· 4445 4704 "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", 4446 4705 "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==" 4447 4706 }, 4707 + "node_modules/psl": { 4708 + "version": "1.15.0", 4709 + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", 4710 + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", 4711 + "dependencies": { 4712 + "punycode": "^2.3.1" 4713 + }, 4714 + "funding": { 4715 + "url": "https://github.com/sponsors/lupomontero" 4716 + } 4717 + }, 4448 4718 "node_modules/punycode": { 4449 4719 "version": "2.3.1", 4450 4720 "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", 4451 4721 "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", 4452 - "dev": true, 4453 4722 "engines": { 4454 4723 "node": ">=6" 4455 4724 } ··· 4869 5138 "version": "5.2.5", 4870 5139 "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.2.5.tgz", 4871 5140 "integrity": "sha512-D33RkKYF4AFIgM+HrItxFudmWrXOLaua8vW3Mq7bObn7UwRn6zJPZ58bEIlj8wEYfi08n8VVvTk8dCLVHNnikQ==", 4872 - "dev": true, 4873 5141 "dependencies": { 4874 5142 "@ampproject/remapping": "^2.3.0", 4875 5143 "@jridgewell/sourcemap-codec": "^1.5.0", ··· 4982 5250 }, 4983 5251 "funding": { 4984 5252 "url": "https://opencollective.com/eslint" 5253 + } 5254 + }, 5255 + "node_modules/svelte-sonner": { 5256 + "version": "0.3.28", 5257 + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-0.3.28.tgz", 5258 + "integrity": "sha512-K3AmlySeFifF/cKgsYNv5uXqMVNln0NBAacOYgmkQStLa/UoU0LhfAACU6Gr+YYC8bOCHdVmFNoKuDbMEsppJg==", 5259 + "peerDependencies": { 5260 + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" 4985 5261 } 4986 5262 }, 4987 5263 "node_modules/tailwind-merge": { ··· 5066 5342 "node": ">= 6" 5067 5343 } 5068 5344 }, 5345 + "node_modules/tailwindcss/node_modules/picomatch": { 5346 + "version": "2.3.1", 5347 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", 5348 + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", 5349 + "dev": true, 5350 + "engines": { 5351 + "node": ">=8.6" 5352 + }, 5353 + "funding": { 5354 + "url": "https://github.com/sponsors/jonschlinkert" 5355 + } 5356 + }, 5069 5357 "node_modules/tailwindcss/node_modules/postcss-load-config": { 5070 5358 "version": "4.0.2", 5071 5359 "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", ··· 5181 5469 "globrex": "^0.1.2" 5182 5470 } 5183 5471 }, 5472 + "node_modules/tlds": { 5473 + "version": "1.255.0", 5474 + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.255.0.tgz", 5475 + "integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==", 5476 + "bin": { 5477 + "tlds": "bin.js" 5478 + } 5479 + }, 5184 5480 "node_modules/to-regex-range": { 5185 5481 "version": "5.0.1", 5186 5482 "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", ··· 5275 5571 "typescript": { 5276 5572 "optional": true 5277 5573 } 5574 + } 5575 + }, 5576 + "node_modules/uint8arrays": { 5577 + "version": "3.0.0", 5578 + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz", 5579 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 5580 + "dependencies": { 5581 + "multiformats": "^9.4.2" 5582 + } 5583 + }, 5584 + "node_modules/undici": { 5585 + "version": "6.21.0", 5586 + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", 5587 + "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", 5588 + "engines": { 5589 + "node": ">=18.17" 5278 5590 } 5279 5591 }, 5280 5592 "node_modules/undici-types": { ··· 5975 6287 "node_modules/zimmerframe": { 5976 6288 "version": "1.1.2", 5977 6289 "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", 5978 - "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", 5979 - "dev": true 6290 + "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==" 6291 + }, 6292 + "node_modules/zod": { 6293 + "version": "3.24.1", 6294 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", 6295 + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", 6296 + "funding": { 6297 + "url": "https://github.com/sponsors/colinhacks" 6298 + } 5980 6299 } 5981 6300 } 5982 6301 }
+3
package.json
··· 39 39 "vite": "^5.0.3" 40 40 }, 41 41 "dependencies": { 42 + "@atproto/api": "^0.13.20", 43 + "@atproto/oauth-client-node": "^0.2.4", 42 44 "@libsql/client": "^0.9.0", 43 45 "@node-rs/argon2": "^1.1.0", 44 46 "@oslojs/crypto": "^1.0.1", ··· 47 49 "dotenv": "^16.4.5", 48 50 "drizzle-orm": "^0.33.0", 49 51 "plyr": "^3.7.8", 52 + "svelte-sonner": "^0.3.28", 50 53 "tailwind-merge": "^2.5.4" 51 54 } 52 55 }
+5
src/app.css
··· 1 1 @import 'tailwindcss/base'; 2 2 @import 'tailwindcss/components'; 3 3 @import 'tailwindcss/utilities'; 4 + 5 + 6 + ::selection { 7 + @apply bg-accent-800/50 text-accent-50; 8 + }
+5 -2
src/app.d.ts
··· 1 + import type { Agent, AtpBaseClient } from '@atproto/api'; 2 + import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 3 + 1 4 // See https://svelte.dev/docs/kit/types#app.d.ts 2 5 // for information about these interfaces 3 6 declare global { 4 7 namespace App { 5 8 interface Locals { 6 - user: import('$lib/server/auth').SessionValidationResult['user']; 7 - session: import('$lib/server/auth').SessionValidationResult['session']; 9 + agent: Agent | AtpBaseClient | undefined; 10 + user: ProfileViewDetailed; 8 11 } 9 12 } 10 13 }
+35 -17
src/hooks.server.ts
··· 1 + import { atclient } from '$lib/server/client'; 2 + import { Agent, AtpBaseClient } from '@atproto/api'; 3 + 4 + import { decryptToString } from '$lib/server/crypts'; 5 + import { decodeBase64, decodeBase64urlIgnorePadding } from '@oslojs/encoding'; 6 + 1 7 import type { Handle } from '@sveltejs/kit'; 2 - import * as auth from '$lib/server/auth.js'; 8 + import { NYX_PASSWORD } from '$env/static/private'; 3 9 4 - const handleAuth: Handle = async ({ event, resolve }) => { 5 - const sessionToken = event.cookies.get(auth.sessionCookieName); 6 - if (!sessionToken) { 7 - event.locals.user = null; 8 - event.locals.session = null; 9 - return resolve(event); 10 - } 10 + export const handle: Handle = async ({ event, resolve }) => { 11 + const sid = event.cookies.get('sid'); 12 + if (sid) { 13 + try { 14 + if (event.locals.user) { 15 + return resolve(event); 16 + } 17 + const decoded = decodeBase64urlIgnorePadding(sid); 18 + const key = decodeBase64(NYX_PASSWORD); 19 + const decrypted = await decryptToString(key, decoded); 20 + const oauthSession = await atclient.restore(decrypted); 11 21 12 - const { session, user } = await auth.validateSessionToken(sessionToken); 13 - if (session) { 14 - auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); 22 + const agent = new Agent(oauthSession); 23 + event.locals.agent = agent; 24 + 25 + const user = await agent.getProfile({ actor: decrypted }); 26 + event.locals.user = user.data; 27 + } catch (error) { 28 + console.error(error); 29 + event.cookies.delete('sid', { path: '/' }); 30 + 31 + const agent = new AtpBaseClient({ service: 'https://api.bsky.app' }); 32 + event.locals.agent = agent; 33 + } 15 34 } else { 16 - auth.deleteSessionTokenCookie(event); 35 + if (event.locals.agent) { 36 + return resolve(event); 37 + } 38 + const agent = new AtpBaseClient({ service: 'https://api.bsky.app' }); 39 + event.locals.agent = agent; 17 40 } 18 41 19 - event.locals.user = user; 20 - event.locals.session = session; 21 - 22 42 return resolve(event); 23 43 }; 24 - 25 - export const handle: Handle = handleAuth;
+21
src/lib/Components/Footer.svelte
··· 1 + <footer class="bg-black border-t border-base-800"> 2 + <div class="mx-auto max-w-7xl px-6 py-12 md:flex md:items-center md:justify-between lg:px-8"> 3 + <div class="flex justify-center gap-x-6 md:order-2"> 4 + 5 + <a href="https://github.com/flo-bit/nyx" class="text-base-400 hover:text-base-300" target="_blank"> 6 + <span class="sr-only">see source code</span> 7 + <svg class="size-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true"> 8 + <path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd" /> 9 + </svg> 10 + </a> 11 + </div> 12 + <div class="md:order-1 mt-8 flex flex-col items-center md:items-start"> 13 + 14 + 15 + <img src="/tmdb_logo.svg" alt="TMDB logo" class="h-3" /> 16 + <p class="mt-2 text-center text-xs text-base-400">This product uses the TMDB API but is not endorsed or certified by TMDB.</p> 17 + 18 + </div> 19 + </div> 20 + </footer> 21 +
+37 -18
src/lib/Components/ItemCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { enhance } from '$app/forms'; 3 - import { watchedItems } from '$lib/state.svelte'; 3 + import { rateMovieModal, watchedItems } from '$lib/state.svelte'; 4 4 import { cn } from '$lib/utils'; 5 + import { toast } from 'svelte-sonner'; 6 + import Rating from './Rating.svelte'; 5 7 const { 6 8 item, 7 9 showMark ··· 12 14 original_name?: string; 13 15 movieId?: number; 14 16 showId?: number; 17 + rating?: number; 15 18 }; 16 19 showMark?: boolean; 17 20 } = $props(); ··· 29 32 /> 30 33 {/if} 31 34 32 - {#if showMark} 33 - <form method="post" action="/?/mark" use:enhance> 34 - <input type="hidden" name="id" value={item.movieId ?? item.showId} /> 35 - <input type="hidden" name="kind" value={item.movieId ? 'movie' : 'tv'} /> 36 - 35 + {#if item.rating} 36 + <div 37 + class="absolute inset-0 z-10 bg-gradient-to-b from-transparent via-black/30 to-black/70" 38 + ></div> 39 + <div class="absolute bottom-2 left-0 right-0 z-10 flex justify-center"> 40 + <Rating rating={item.rating} /> 41 + </div> 42 + {:else if showMark} 43 + {#if watchedItems.hasRated(item)} 44 + <div 45 + class="absolute inset-0 z-10 bg-gradient-to-b from-transparent via-black/30 to-black/70" 46 + ></div> 47 + <div class="absolute bottom-2 left-0 right-0 z-10 flex justify-center"> 48 + <Rating rating={watchedItems.getRating(item) ?? 0} /> 49 + </div> 50 + {:else} 37 51 <button 38 52 class={cn( 39 - 'pointer-events-auto absolute bottom-2 right-2 z-20 rounded-full border border-base-50/20 bg-black/30 p-2 text-base-50 backdrop-blur-sm group-hover:block sm:hidden', 40 - watchedItems.hasWatched(item) 41 - ? 'border-green-500/20 bg-green-900/60 text-green-500 sm:block' 42 - : '' 53 + 'pointer-events-auto absolute bottom-2 right-2 z-10 rounded-full border border-base-50/20 bg-black/30 p-2 text-base-50 opacity-100 backdrop-blur-sm transition-opacity duration-300 ease-in-out group-hover:opacity-100 sm:opacity-0', 54 + watchedItems.hasRated(item) 55 + ? 'border-green-500/20 bg-green-900/60 text-green-500 sm:opacity-100' 56 + : 'hover:border-accent-800/50 hover:bg-accent-900/50 hover:text-accent-400 hover:duration-0' 43 57 )} 44 58 onclick={() => { 45 - if (watchedItems.hasWatched(item)) { 46 - watchedItems.removeWatched(item); 47 - } else { 48 - watchedItems.addWatched(item); 49 - } 59 + rateMovieModal.selectedItem = { 60 + movieId: item.movieId, 61 + showId: item.showId, 62 + kind: item.movieId ? 'movie' : 'tv', 63 + name: item.original_title ?? item.original_name, 64 + currentRating: 0, 65 + currentReview: '' 66 + }; 67 + rateMovieModal.showModal = true; 50 68 }} 51 69 > 52 - <span class="sr-only">mark as watched</span> 70 + <span class="sr-only">rate</span> 71 + 53 72 <svg 54 73 xmlns="http://www.w3.org/2000/svg" 55 74 fill="none" ··· 58 77 stroke="currentColor" 59 78 class="size-5" 60 79 > 61 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 80 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 62 81 </svg> 63 82 </button> 64 - </form> 83 + {/if} 65 84 {/if} 66 85 </div> 67 86 <div class="mt-2 flex justify-between">
+61
src/lib/Components/LoginModal.svelte
··· 1 + <script lang="ts"> 2 + import { fade, fly, slide } from 'svelte/transition'; 3 + import { showLoginModel } from '$lib/state.svelte'; 4 + 5 + let loginModal = showLoginModel(); 6 + </script> 7 + 8 + {#if loginModal.value} 9 + <div 10 + class="fixed inset-0 z-[100] w-screen overflow-y-auto" 11 + aria-labelledby="modal-title" 12 + role="dialog" 13 + aria-modal="true" 14 + > 15 + <div 16 + class="fixed inset-0 bg-base-950/75 backdrop-blur-sm transition-opacity" 17 + onclick={() => loginModal.toggle()} 18 + aria-hidden="true" 19 + ></div> 20 + 21 + <div class="pointer-events-none fixed inset-0 z-10 w-screen overflow-y-auto"> 22 + <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> 23 + <div 24 + class="pointer-events-auto relative transform overflow-hidden rounded-lg border border-base-700 bg-base-800 px-4 pb-4 pt-2 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6" 25 + > 26 + <div> 27 + <div class="text-center"> 28 + <h3 class="text-base font-semibold text-base-900 dark:text-base-100" id="modal-title"> 29 + Login with Bluesky 30 + </h3> 31 + </div> 32 + </div> 33 + <form action="/?/login" method="POST" class="mt-2 flex w-full flex-col gap-2"> 34 + <div class="w-full"> 35 + <label 36 + for="handle" 37 + class="block text-sm/6 font-medium text-base-900 dark:text-base-100">Handle</label 38 + > 39 + <div class="mt-2"> 40 + <input 41 + type="text" 42 + name="handle" 43 + id="handle" 44 + class="block w-full rounded-md border-0 py-1.5 text-base-900 shadow-sm ring-1 ring-inset ring-base-300 placeholder:text-base-400 focus:ring-2 focus:ring-inset focus:ring-accent-600 sm:text-sm/6 dark:bg-base-900 dark:text-base-100 dark:ring-base-700 dark:placeholder:text-base-600" 45 + placeholder="yourname.blsky.social" 46 + /> 47 + </div> 48 + </div> 49 + <div class="mt-5 sm:mt-6"> 50 + <button 51 + type="submit" 52 + class="inline-flex w-full justify-center rounded-md bg-accent-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-accent-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600" 53 + >Login</button 54 + > 55 + </div> 56 + </form> 57 + </div> 58 + </div> 59 + </div> 60 + </div> 61 + {/if}
+80
src/lib/Components/RateMovieModal.svelte
··· 1 + <script lang="ts"> 2 + import { rateMovieModal, watchedItems } from '$lib/state.svelte'; 3 + import Rating from './Rating.svelte'; 4 + 5 + let rating = $state(rateMovieModal.selectedItem.currentRating ?? 0); 6 + let review = $state(rateMovieModal.selectedItem.currentReview ?? ''); 7 + 8 + $effect(() => { 9 + rating = rateMovieModal.selectedItem.currentRating ?? 0; 10 + review = rateMovieModal.selectedItem.currentReview ?? ''; 11 + }); 12 + </script> 13 + 14 + {#if rateMovieModal.showModal} 15 + <div class="relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true"> 16 + <div 17 + class="fixed inset-0 bg-base-950/90 backdrop-blur-sm transition-opacity" 18 + onclick={() => (rateMovieModal.showModal = false)} 19 + aria-hidden="true" 20 + ></div> 21 + <div class="pointer-events-none fixed inset-0 h-screen z-50 w-screen overflow-y-auto"> 22 + <div class="flex h-screen items-end justify-center p-4 text-center sm:items-center sm:p-0"> 23 + 24 + <div 25 + class="pointer-events-auto relative w-full transform overflow-hidden rounded-lg border border-base-700 bg-base-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:max-w-sm sm:p-6" 26 + > 27 + <div> 28 + <h3 class="text-lg font-semibold text-base-50" id="modal-title">Rate {rateMovieModal.selectedItem.name}</h3> 29 + 30 + <div class="mt-4 flex items-center gap-2"> 31 + <p class="text-xs font-medium text-base-50">stars</p> 32 + 33 + <Rating bind:rating={rating} /> 34 + </div> 35 + 36 + <div class="mt-4"> 37 + <label for="comment" class="block text-xs font-medium text-base-50">review</label> 38 + <div class="mt-2"> 39 + <textarea 40 + rows="4" 41 + name="comment" 42 + id="comment" 43 + bind:value={review} 44 + placeholder="write a review" 45 + class="block w-full rounded-md bg-base-900 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" 46 + ></textarea> 47 + </div> 48 + </div> 49 + </div> 50 + <div class="mt-5 sm:mt-6"> 51 + <button 52 + onclick={async () => { 53 + rateMovieModal.showModal = false; 54 + 55 + await fetch(`/api/rate`, { 56 + method: 'POST', 57 + body: JSON.stringify({ 58 + rating, 59 + review, 60 + kind: rateMovieModal.selectedItem.kind, 61 + id: rateMovieModal.selectedItem.movieId ?? rateMovieModal.selectedItem.showId 62 + }) 63 + }); 64 + 65 + watchedItems.addRated({ 66 + movieId: rateMovieModal.selectedItem.movieId, 67 + showId: rateMovieModal.selectedItem.showId, 68 + rating 69 + }); 70 + }} 71 + type="button" 72 + 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" 73 + >save</button 74 + > 75 + </div> 76 + </div> 77 + </div> 78 + </div> 79 + </div> 80 + {/if}
+7 -3
src/lib/Components/Rating.svelte
··· 1 1 <script lang="ts"> 2 2 import { cn } from '$lib/utils'; 3 - const { rating = 0 }: { rating: number } = $props(); 3 + let { rating = $bindable() }: { rating: number } = $props(); 4 4 5 5 let hoverRating = $state(rating); 6 + 7 + $effect(() => { 8 + hoverRating = rating; 9 + }); 6 10 </script> 7 11 8 12 <div class="flex items-center xl:col-span-1"> 9 13 <div class="flex items-center"> 10 14 {#each Array.from({ length: 5 }).map((_, i) => i + 1) as i} 11 - <button> 15 + <button onclick={() => (rating = i)}> 12 16 <svg 13 - class={cn('size-5 shrink-0 text-base-200/20 stroke-base-50/20', i <= hoverRating && 'text-accent-500 stroke-accent-400')} 17 + class={cn('size-5 shrink-0 text-base-600 stroke-base-500', i <= hoverRating && 'text-accent-500 stroke-accent-400')} 14 18 viewBox="0 0 24 24" 15 19 fill="currentColor" 16 20 aria-hidden="true"
+5 -1
src/lib/Components/VideoPlayer.svelte
··· 1 1 <script lang="ts"> 2 2 import { cn } from '../utils'; 3 - import { onMount } from 'svelte'; 3 + import { onMount, tick } from 'svelte'; 4 4 import Plyr from 'plyr'; 5 5 6 6 const { id, class: className }: { id: string; class?: string } = $props(); ··· 10 10 const player = new Plyr('.js-player', { 11 11 settings: ['captions', 'quality', 'loop', 'controls'] 12 12 }); 13 + 14 + return () => { 15 + player.destroy(); 16 + }; 13 17 }); 14 18 </script> 15 19
+18
src/lib/bluesky.ts
··· 1 + import { Agent, AtpBaseClient } from '@atproto/api'; 2 + 3 + type AgentType = Agent | AtpBaseClient | null; 4 + 5 + export async function resolveHandle({ 6 + handle, 7 + agent = undefined 8 + }: { 9 + handle: string; 10 + agent?: AgentType; 11 + }) { 12 + if (!agent) { 13 + agent = new AtpBaseClient({ service: 'https://api.bsky.app' }); 14 + } 15 + 16 + const data = await agent.com.atproto.identity.resolveHandle({ handle }); 17 + return data.data.did; 18 + }
+29
src/lib/server/client.ts
··· 1 + import { NodeOAuthClient } from '@atproto/oauth-client-node'; 2 + import { db } from './db'; 3 + import { SessionStore, StateStore } from './storage'; 4 + import { dev } from '$app/environment'; 5 + 6 + const publicUrl = 'https://nyx-kappa.vercel.app/'; 7 + const port = 5173; 8 + const url = dev ? `http://[::1]:${port}` : publicUrl; // since I'm using ipv6, use ::1 instead 127.0.0.1 9 + const enc = encodeURIComponent; 10 + 11 + const clientId = !dev 12 + ? `${publicUrl}/client-metadata.json` 13 + : `http://localhost?redirect_uri=${enc(`${url}/oauth/callback`)}&scope=${enc('atproto transition:generic')}`; 14 + 15 + export const atclient = new NodeOAuthClient({ 16 + stateStore: new StateStore(db), 17 + sessionStore: new SessionStore(db), 18 + clientMetadata: { 19 + client_name: 'nyx', 20 + client_id: clientId, 21 + client_uri: url, 22 + redirect_uris: [`${url}/oauth/callback`], 23 + scope: 'atproto transition:generic', 24 + grant_types: ['authorization_code', 'refresh_token'], 25 + application_type: 'web', 26 + token_endpoint_auth_method: 'none', 27 + dpop_bound_access_tokens: true 28 + } 29 + });
+49
src/lib/server/crypts.ts
··· 1 + // Code by @pilcrowonpaper on GitHub: https://gist.github.com/pilcrowonpaper/353318556029221c8e25f451b91e5f76 2 + // AES128 with the Web Crypto API. 3 + async function encrypt(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> { 4 + const iv = new Uint8Array(16); 5 + crypto.getRandomValues(iv); 6 + const cryptoKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt"]); 7 + const cipher = await crypto.subtle.encrypt( 8 + { 9 + name: "AES-GCM", 10 + iv, 11 + tagLength: 128 12 + }, 13 + cryptoKey, 14 + data 15 + ); 16 + const encrypted = new Uint8Array(iv.byteLength + cipher.byteLength); 17 + encrypted.set(iv); 18 + encrypted.set(new Uint8Array(cipher), iv.byteLength); 19 + return encrypted; 20 + } 21 + 22 + export async function encryptString(key: Uint8Array, data: string): Promise<Uint8Array> { 23 + const encoded = new TextEncoder().encode(data); 24 + const encrypted = await encrypt(key, encoded); 25 + return encrypted; 26 + } 27 + 28 + async function decrypt(key: Uint8Array, encrypted: Uint8Array): Promise<Uint8Array> { 29 + if (encrypted.length < 16) { 30 + throw new Error("Invalid data"); 31 + } 32 + const cryptoKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["decrypt"]); 33 + const decrypted = await crypto.subtle.decrypt( 34 + { 35 + name: "AES-GCM", 36 + iv: encrypted.slice(0, 16), 37 + tagLength: 128 38 + }, 39 + cryptoKey, 40 + encrypted.slice(16) 41 + ); 42 + return new Uint8Array(decrypted); 43 + } 44 + 45 + export async function decryptToString(key: Uint8Array, data: Uint8Array): Promise<string> { 46 + const decrypted = await decrypt(key, data); 47 + const decoded = new TextDecoder().decode(decrypted); 48 + return decoded; 49 + }
+3 -1
src/lib/server/db/index.ts
··· 2 2 import { drizzle } from 'drizzle-orm/libsql'; 3 3 import { createClient } from '@libsql/client'; 4 4 import { env } from '$env/dynamic/private'; 5 + import * as schema from './schema'; 6 + 5 7 if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); 6 8 if (!dev && !env.DATABASE_AUTH_TOKEN) throw new Error('DATABASE_AUTH_TOKEN is not set'); 7 9 const client = createClient({ url: env.DATABASE_URL, authToken: env.DATABASE_AUTH_TOKEN }); 8 - export const db = drizzle(client); 10 + export const db = drizzle(client, { schema: schema });
+7 -18
src/lib/server/db/schema.ts
··· 1 1 import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; 2 2 3 - export const user = sqliteTable('user', { 4 - id: text('id').primaryKey(), 5 - age: integer('age'), 6 - username: text('username').notNull().unique(), 7 - passwordHash: text('password_hash').notNull() 3 + export const AuthSession = sqliteTable('auth_session', { 4 + key: text('key', { mode: 'text' }).primaryKey().unique(), 5 + session: text('session', { mode: 'json' }).notNull() 8 6 }); 9 7 10 - export const session = sqliteTable('session', { 11 - id: text('id').primaryKey(), 12 - userId: text('user_id') 13 - .notNull() 14 - .references(() => user.id), 15 - expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull() 8 + export const AuthState = sqliteTable('auth_state', { 9 + key: text('key', { mode: 'text' }).primaryKey().unique(), 10 + state: text('state', { mode: 'json' }).notNull() 16 11 }); 17 12 18 13 export const items = sqliteTable('items', { 19 14 id: text('id').primaryKey(), 20 15 movieId: integer('movie_id'), 21 16 showId: integer('show_id'), 22 - username: text('username') 23 - .notNull() 24 - .references(() => user.username), 17 + did: text('did').notNull(), 25 18 watched: integer('watched').notNull(), 26 19 rating: integer('rating'), 27 20 ratingText: text('rating_text'), ··· 29 22 originalTitle: text('original_title').notNull(), 30 23 timestamp: integer('timestamp', { mode: 'timestamp' }).notNull() 31 24 }); 32 - 33 - export type Session = typeof session.$inferSelect; 34 - 35 - export type User = typeof user.$inferSelect; 36 25 37 26 export type Movies = typeof items.$inferSelect;
+34 -14
src/lib/server/movies.ts
··· 81 81 return data.results; 82 82 } 83 83 84 - export async function checkWatched( 85 - id: number, 86 - username: string, 87 - kind: Kind 88 - ): Promise<boolean | number> { 84 + export async function getWatchProviders(id: number, kind: Kind) { 85 + const url = `https://api.themoviedb.org/3/${kind}/${id}/watch/providers?language=en-US`; 86 + const options = { 87 + method: 'GET', 88 + headers: { 89 + accept: 'application/json', 90 + Authorization: `Bearer ${env.TMDB_API_KEY}` 91 + } 92 + }; 93 + 94 + const response = await fetch(url, options); 95 + const data = await response.json(); 96 + 97 + return data.results; 98 + } 99 + 100 + export async function checkWatched(id: number, did: string, kind: Kind): Promise<boolean | number> { 89 101 const movie = await db 90 102 .select() 91 103 .from(table.items) 92 104 .where( 93 105 and( 94 106 eq(kind === 'movie' ? table.items.movieId : table.items.showId, id), 95 - eq(table.items.username, username) 107 + eq(table.items.did, did) 96 108 ) 97 109 ); 98 110 ··· 101 113 return false; 102 114 } 103 115 104 - export async function getWatchedMoviesIds(username: string) { 116 + export async function getWatchedMoviesIds(did: string) { 105 117 const movies = await db 106 - .select({ movieId: table.items.movieId }) 118 + .select({ 119 + movieId: table.items.movieId, 120 + rating: table.items.rating, 121 + ratingText: table.items.ratingText 122 + }) 107 123 .from(table.items) 108 - .where(and(eq(table.items.username, username), eq(table.items.watched, 1))); 124 + .where(and(eq(table.items.did, did), eq(table.items.watched, 1))); 109 125 110 - return new Set(movies.map((movie) => movie.movieId ?? 0)); 126 + return new Map(movies.map((movie) => [movie.movieId ?? 0, movie.rating ?? 0])); 111 127 } 112 128 113 - export async function getWatchedShowsIds(username: string) { 129 + export async function getWatchedShowsIds(did: string) { 114 130 const shows = await db 115 - .select({ showId: table.items.showId }) 131 + .select({ 132 + showId: table.items.showId, 133 + rating: table.items.rating, 134 + ratingText: table.items.ratingText 135 + }) 116 136 .from(table.items) 117 - .where(and(eq(table.items.username, username), eq(table.items.watched, 1))); 137 + .where(and(eq(table.items.did, did), eq(table.items.watched, 1))); 118 138 119 - return new Set(shows.map((show) => show.showId ?? 0)); 139 + return new Map(shows.map((show) => [show.showId ?? 0, show.rating ?? 0])); 120 140 }
+57
src/lib/server/storage.ts
··· 1 + import type { 2 + NodeSavedSession, 3 + NodeSavedSessionStore, 4 + NodeSavedState, 5 + NodeSavedStateStore 6 + } from '@atproto/oauth-client-node'; 7 + import { eq } from 'drizzle-orm'; 8 + import { db as database } from './db'; 9 + import * as schema from './db/schema'; 10 + 11 + export class StateStore implements NodeSavedStateStore { 12 + constructor(private db: typeof database) {} 13 + async get(key: string): Promise<NodeSavedState | undefined> { 14 + const result = await this.db.query.AuthState.findFirst({ 15 + where: eq(schema.AuthState.key, key) 16 + }); 17 + if (!result) return; 18 + return JSON.parse(result.state as string) as NodeSavedState; 19 + } 20 + async set(key: string, val: NodeSavedState) { 21 + const state = JSON.stringify(val); 22 + await this.db 23 + .insert(schema.AuthState) 24 + .values({ 25 + key, 26 + state 27 + }) 28 + .onConflictDoUpdate({ 29 + target: schema.AuthState.key, 30 + set: { state } 31 + }); 32 + } 33 + async del(key: string) { 34 + await this.db.delete(schema.AuthState).where(eq(schema.AuthState.key, key)); 35 + } 36 + } 37 + 38 + export class SessionStore implements NodeSavedSessionStore { 39 + constructor(private db: typeof database) {} 40 + async get(key: string): Promise<NodeSavedSession | undefined> { 41 + const result = await this.db.query.AuthSession.findFirst({ 42 + where: eq(schema.AuthSession.key, key) 43 + }); 44 + if (!result) return; 45 + return JSON.parse(result.session as string) as NodeSavedSession; 46 + } 47 + async set(key: string, val: NodeSavedSession) { 48 + const session = JSON.stringify(val); 49 + await this.db.insert(schema.AuthSession).values({ key, session }).onConflictDoUpdate({ 50 + target: schema.AuthSession.key, 51 + set: { session } 52 + }); 53 + } 54 + async del(key: string) { 55 + await this.db.delete(schema.AuthSession).where(eq(schema.AuthSession.key, key)); 56 + } 57 + }
+49 -11
src/lib/state.svelte.ts
··· 1 1 export const watchedItems = $state({ 2 - watchedMovies: new Set<number>(), 3 - watchedShows: new Set<number>(), 2 + ratedMovies: new Map<number, number>(), 3 + ratedShows: new Map<number, number>(), 4 4 5 - hasWatched: (item: { movieId?: number; showId?: number }) => { 5 + hasRated: (item: { movieId?: number; showId?: number }) => { 6 6 const id = item.movieId ?? item.showId; 7 7 if (!id) return false; 8 - return item.movieId ? watchedItems.watchedMovies.has(id) : watchedItems.watchedShows.has(id); 8 + return item.movieId ? watchedItems.ratedMovies.has(id) : watchedItems.ratedShows.has(id); 9 9 }, 10 10 11 - addWatched: (item: { movieId?: number; showId?: number }) => { 11 + addRated: (item: { movieId?: number; showId?: number; rating: number }) => { 12 12 const id = item.movieId ?? item.showId; 13 13 if (!id) return; 14 - if (item.movieId) watchedItems.watchedMovies.add(id); 15 - else watchedItems.watchedShows.add(id); 14 + if (item.movieId) watchedItems.ratedMovies.set(id, item.rating); 15 + else watchedItems.ratedShows.set(id, item.rating); 16 16 }, 17 17 18 - removeWatched: (item: { movieId?: number; showId?: number }) => { 18 + getRating: (item: { movieId?: number; showId?: number }) => { 19 19 const id = item.movieId ?? item.showId; 20 - if (!id) return; 21 - if (item.movieId) watchedItems.watchedMovies.delete(id); 22 - else watchedItems.watchedShows.delete(id); 20 + if (!id) return 0; 21 + return item.movieId ? watchedItems.ratedMovies.get(id) : watchedItems.ratedShows.get(id); 23 22 } 24 23 }); 24 + 25 + export const rateMovieModal: { 26 + showModal: boolean; 27 + 28 + selectedItem: { 29 + movieId: number | undefined; 30 + showId: number | undefined; 31 + kind: string | undefined; 32 + name: string | undefined; 33 + currentRating: number | undefined; 34 + currentReview: string | undefined; 35 + }; 36 + } = $state({ 37 + showModal: false, 38 + 39 + selectedItem: { 40 + movieId: undefined, 41 + showId: undefined, 42 + kind: undefined, 43 + name: undefined, 44 + currentRating: undefined, 45 + currentReview: undefined 46 + } 47 + }); 48 + 49 + let showLoginModelState = $state(false); 50 + 51 + export function showLoginModel() { 52 + function toggle() { 53 + showLoginModelState = !showLoginModelState; 54 + } 55 + 56 + return { 57 + get value() { 58 + return showLoginModelState; 59 + }, 60 + toggle 61 + }; 62 + }
+3 -3
src/routes/+layout.server.ts
··· 3 3 4 4 export const load: LayoutServerLoad = async (event) => { 5 5 if (event.locals.user) { 6 - const watchedMovies = await getWatchedMoviesIds(event.locals.user.username); 7 - const watchedShows = await getWatchedShowsIds(event.locals.user.username); 6 + const watchedMovies = await getWatchedMoviesIds(event.locals.user.did); 7 + const watchedShows = await getWatchedShowsIds(event.locals.user.did); 8 8 9 9 return { user: event.locals.user, watchedMovies, watchedShows }; 10 10 } 11 11 12 - return {}; 12 + return { user: event.locals.user }; 13 13 };
+45 -8
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 + import Footer from '$lib/Components/Footer.svelte'; 2 3 import Logo from '$lib/Components/Logo.svelte'; 4 + import { Toaster } from 'svelte-sonner'; 3 5 import { watchedItems } from '$lib/state.svelte'; 4 6 import '../app.css'; 7 + import RateMovieModal from '$lib/Components/RateMovieModal.svelte'; 8 + 5 9 let { children, data } = $props(); 6 10 7 - watchedItems.watchedMovies = new Set(data.watchedMovies); 8 - watchedItems.watchedShows = new Set(data.watchedShows); 11 + import { showLoginModel } from '$lib/state.svelte'; 12 + import LoginModal from '$lib/Components/LoginModal.svelte'; 13 + let loginModal = showLoginModel(); 14 + 15 + watchedItems.ratedMovies = new Map(data.watchedMovies); 16 + watchedItems.ratedShows = new Map(data.watchedShows); 9 17 </script> 10 18 11 19 <header class="absolute inset-x-0 top-0 z-50"> ··· 36 44 </svg> 37 45 </a> 38 46 {/if} 39 - <a 40 - href="/login" 41 - class="text-sm/6 font-semibold text-white transition-colors duration-75 hover:text-accent-400" 42 - >account <span aria-hidden="true">&rarr;</span></a 43 - > 47 + {#if !data.user} 48 + <button 49 + onclick={() => loginModal.toggle()} 50 + class="text-sm/6 font-semibold text-white transition-colors duration-75 hover:text-accent-400" 51 + > 52 + login 53 + </button> 54 + {:else} 55 + <a 56 + href="/user/{data.user.handle}" 57 + class="text-sm/6 font-semibold text-white transition-colors duration-75 hover:text-accent-400" 58 + > 59 + account <span aria-hidden="true">&rarr;</span> 60 + </a> 61 + {/if} 44 62 </div> 45 63 </nav> 46 64 </header> 47 65 48 - {@render children()} 66 + <div class="min-h-screen"> 67 + {@render children()} 68 + </div> 69 + <Footer /> 70 + 71 + <Toaster 72 + toastOptions={{ 73 + unstyled: true, 74 + classes: { 75 + toast: 76 + 'bg-base-900/70 backdrop-blur-sm border border-base-800 rounded-md shadow-md p-4 flex items-center gap-2 fixed bottom-4 right-4', 77 + title: 'text-white', 78 + description: 'text-white' 79 + } 80 + }} 81 + /> 82 + 83 + <RateMovieModal /> 84 + 85 + <LoginModal /> 49 86 50 87 <div 51 88 style="background-image: url(/nnnoise.svg)"
+26 -5
src/routes/+page.server.ts
··· 1 1 import { db } from '$lib/server/db'; 2 2 import { checkWatched, getDetails, type Kind } from '$lib/server/movies'; 3 - import type { Actions } from './$types'; 4 3 import * as table from '$lib/server/db/schema'; 5 4 import { eq, and } from 'drizzle-orm'; 6 5 6 + import { atclient } from '$lib/server/client'; 7 + import { isValidHandle } from '@atproto/syntax'; 8 + import { error, redirect, type Actions } from '@sveltejs/kit'; 9 + 7 10 export const actions: Actions = { 8 11 mark: async (event) => { 9 - const username = event.locals.user?.username as string; 12 + const did = event.locals.user?.did as string; 10 13 const formData = await event.request.formData(); 11 14 const id = parseInt(formData.get('id') as string); 12 15 const kind = formData.get('kind') as Kind; 13 16 14 17 const result = await getDetails(id, kind); 15 18 16 - const watched = await checkWatched(id, username, kind); 19 + const watched = await checkWatched(id, did, kind); 17 20 18 21 if (watched !== false) { 19 22 await db ··· 22 25 .where( 23 26 and( 24 27 eq(kind === 'movie' ? table.items.movieId : table.items.showId, id), 25 - eq(table.items.username, username) 28 + eq(table.items.did, did) 26 29 ) 27 30 ); 28 31 } else { 29 32 await db.insert(table.items).values({ 30 - username, 33 + did: did, 31 34 id: crypto.randomUUID(), 32 35 movieId: kind === 'movie' ? id : null, 33 36 showId: kind === 'tv' ? id : null, ··· 37 40 timestamp: new Date() 38 41 }); 39 42 } 43 + }, 44 + login: async ({ request }) => { 45 + const formData = await request.formData(); 46 + const handle = formData.get('handle') as string; 47 + if (!isValidHandle(handle)) { 48 + error(400, { message: 'invalid handle' }); 49 + } 50 + 51 + const url = await atclient.authorize(handle, { scope: 'atproto transition:generic' }); 52 + if (!url) { 53 + error(500); 54 + } 55 + redirect(301, url.toString()); 56 + }, 57 + logout: async ({ cookies }) => { 58 + cookies.delete('sid', { path: '/' }); 59 + redirect(301, '/'); 40 60 } 41 61 }; 62 +
+3 -3
src/routes/+page.svelte
··· 9 9 aria-hidden="true" 10 10 > 11 11 <div 12 - class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-20 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]" 12 + class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-accent-500 to-accent-400 opacity-20 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]" 13 13 style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)" 14 14 ></div> 15 15 </div> ··· 19 19 nyx 20 20 </h1> 21 21 <p class="text-md mt-8 text-pretty font-medium text-base-400 sm:text-lg"> 22 - social media but for movies. work in progress. 22 + social media for movies. work in progress. 23 23 </p> 24 24 </div> 25 25 ··· 28 28 aria-hidden="true" 29 29 > 30 30 <div 31 - class="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-20 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]" 31 + class="relative left-[calc(50%+3rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 bg-gradient-to-tr from-accent-500 to-accent-400 opacity-20 sm:left-[calc(50%+36rem)] sm:w-[72.1875rem]" 32 32 style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)" 33 33 ></div> 34 34 </div>
+6 -2
src/routes/[kind]/[id]/+page.server.ts
··· 1 - import { getDetails, getRecommendations, getTrailer } from '$lib/server/movies'; 1 + import { getDetails, getRecommendations, getTrailer, getWatchProviders } from '$lib/server/movies'; 2 2 import { error } from '@sveltejs/kit'; 3 3 4 4 /** @type {import('./$types').PageServerLoad} */ ··· 33 33 } 34 34 }); 35 35 36 - return { result, trailer, recommendations, kind }; 36 + const watchProviders = await getWatchProviders(id, kind); 37 + 38 + watchProviders.DE?.flatrate?.sort((a, b) => a.display_priority - b.display_priority); 39 + 40 + return { result, trailer, recommendations, kind, watchProviders }; 37 41 } 38 42 39 43 // not found
+77 -50
src/routes/[kind]/[id]/+page.svelte
··· 2 2 import { type PageData } from './$types'; 3 3 import { enhance } from '$app/forms'; 4 4 import { cn } from '$lib/utils'; 5 - import { watchedItems } from '$lib/state.svelte'; 5 + import { rateMovieModal, watchedItems } from '$lib/state.svelte'; 6 6 7 7 import Container from '$lib/Components/Container.svelte'; 8 8 import VideoPlayer from '$lib/Components/VideoPlayer.svelte'; ··· 24 24 <img 25 25 src="https://image.tmdb.org/t/p/w500{data.result.poster_path}" 26 26 alt="" 27 - class="h-auto w-24 rounded-lg border border-white/10 sm:w-44" 27 + class="h-36 w-24 shrink-0 rounded-lg border border-white/10 sm:h-64 sm:w-44" 28 28 /> 29 29 <div class="flex flex-col gap-4"> 30 30 <div class="max-w-xl text-2xl font-semibold text-white sm:text-4xl"> ··· 32 32 </div> 33 33 {#if data.user} 34 34 <div class="flex gap-4"> 35 - <form method="post" action="/?/mark" use:enhance> 36 - <input type="hidden" name="id" value={data.result.id} /> 37 - <input type="hidden" name="kind" value={data.kind} /> 38 - 39 - <button 40 - class={cn( 41 - '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 ', 42 - watchedItems.hasWatched(data.result) ? 'bg-green-500/10 text-green-400 hover:bg-green-500/20' : '' 43 - )} 35 + <button 36 + class={cn( 37 + '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 ', 38 + watchedItems.hasRated(data.result) 39 + ? 'bg-green-500/10 text-green-400 hover:bg-green-500/20' 40 + : '' 41 + )} 42 + onclick={() => { 43 + rateMovieModal.selectedItem = { 44 + movieId: data.result.movieId, 45 + showId: data.result.showId, 46 + kind: data.result.movieId ? 'movie' : 'tv', 47 + name: data.result.original_title ?? data.result.original_name, 48 + currentRating: watchedItems.getRating(data.result) ?? 0, 49 + currentReview: '' 50 + }; 44 51 45 - onclick={() => { 46 - if (watchedItems.hasWatched(data.result)) { 47 - watchedItems.removeWatched(data.result); 48 - } else { 49 - watchedItems.addWatched(data.result); 50 - } 51 - }} 52 + rateMovieModal.showModal = true; 53 + }} 54 + > 55 + {#if watchedItems.hasRated(data.result)} 56 + <svg 57 + xmlns="http://www.w3.org/2000/svg" 58 + fill="none" 59 + viewBox="0 0 24 24" 60 + stroke-width="2.5" 61 + stroke="currentColor" 62 + class="size-5" 63 + > 64 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 65 + </svg> 66 + rated 67 + {:else} 68 + rate 69 + {/if} 70 + </button> 71 + 72 + <Rating rating={watchedItems.getRating(data.result) ?? 0} /> 73 + </div> 74 + {/if} 75 + 76 + {#if data.watchProviders.DE?.flatrate} 77 + <div class="mt-4 text-sm text-white"> 78 + <div class="mb-2 flex flex-wrap gap-4 text-xs font-medium"> 79 + stream on 80 + <span class="text-base-400" 81 + >(powered by <a 82 + href="https://www.justwatch.com" 83 + target="_blank" 84 + class="text-base-300 hover:text-accent-300">justwatch</a 85 + >)</span 52 86 > 53 - {#if watchedItems.hasWatched(data.result)} 54 - <svg 55 - xmlns="http://www.w3.org/2000/svg" 56 - fill="none" 57 - viewBox="0 0 24 24" 58 - stroke-width="2.5" 59 - stroke="currentColor" 60 - class="size-5" 61 - > 62 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 63 - </svg> 64 - watched 65 - {:else} 66 - mark watched 67 - {/if} 68 - </button> 69 - </form> 70 - 71 - <Rating rating={Math.round(data.result.vote_average/2)} /> 87 + </div> 88 + <a href={data.watchProviders.DE.link} target="_blank" class="flex flex-wrap gap-2"> 89 + {#each data.watchProviders.DE.flatrate as provider} 90 + <img 91 + src="https://image.tmdb.org/t/p/w500{provider.logo_path}" 92 + alt={provider.provider_name} 93 + class="size-8 rounded-md border border-base-800 md:size-12" 94 + /> 95 + {/each} 96 + </a> 72 97 </div> 73 98 {/if} 74 99 </div> 75 100 </div> 76 101 77 - <div class="my-8 max-w-2xl text-sm text-white"> 78 - <div class="mb-2 text-lg font-semibold">overview</div> 79 - {data.result.overview} 80 - </div> 102 + <div class="my-8 text-sm text-white"> 103 + <div class="my-8 max-w-2xl text-sm text-white"> 104 + <div class="mb-2 text-lg font-semibold">overview</div> 105 + {data.result.overview} 106 + </div> 81 107 82 - {#if data.trailer} 83 - <div class="mb-2 text-lg font-semibold">trailer</div> 108 + {#if data.trailer} 109 + <div class="mb-2 text-lg font-semibold">trailer</div> 84 110 85 - <VideoPlayer id={data.trailer} /> 86 - {/if} 111 + <VideoPlayer id={data.trailer} /> 112 + {/if} 87 113 88 - {#if data.recommendations.length > 0} 89 - <div class="mb-2 mt-8 text-lg font-semibold">recommendations</div> 114 + {#if data.recommendations.length > 0} 115 + <div class="mb-2 mt-8 text-lg font-semibold">recommendations</div> 90 116 91 - <ItemsList items={data.recommendations} showMark={!!data.user} /> 92 - {/if} 93 - </Container> 117 + <ItemsList items={data.recommendations} showMark={!!data.user} /> 118 + {/if} 119 + </div></Container 120 + >
+52
src/routes/api/rate/+server.ts
··· 1 + import { db } from '$lib/server/db'; 2 + import { checkWatched, getDetails } from '$lib/server/movies'; 3 + import { error, json, type RequestHandler } from '@sveltejs/kit'; 4 + import * as table from '$lib/server/db/schema'; 5 + import { eq, and } from 'drizzle-orm'; 6 + 7 + // Given a cursor and limit (opt) 8 + // Return a JSON of FeedViewPost 9 + export const POST: RequestHandler = async ({ request, locals }) => { 10 + const user = locals.user; 11 + if (!user) { 12 + return error(401, 'Unauthorized API call'); 13 + } 14 + 15 + const body = await request.json(); 16 + const rating = body.rating; 17 + const review = body.review; 18 + const kind = body.kind; 19 + const id = body.id; 20 + const did = user.did; 21 + 22 + const result = await getDetails(id, kind); 23 + const watched = await checkWatched(id, did, kind); 24 + 25 + if (watched !== false) { 26 + await db 27 + .update(table.items) 28 + .set({ rating, ratingText: review }) 29 + .where( 30 + and( 31 + eq(kind === 'movie' ? table.items.movieId : table.items.showId, id), 32 + eq(table.items.did, did) 33 + ) 34 + ); 35 + } else { 36 + await db.insert(table.items).values({ 37 + did: did, 38 + id: crypto.randomUUID(), 39 + movieId: kind === 'movie' ? id : null, 40 + showId: kind === 'tv' ? id : null, 41 + watched: 1, 42 + originalTitle: result.original_title ?? result.original_name, 43 + posterPath: result.poster_path, 44 + timestamp: new Date(), 45 + rating, 46 + ratingText: review 47 + }); 48 + } 49 + console.log('rated'); 50 + 51 + return json({ status: 'rated' }); 52 + };
+1 -1
src/routes/login/+page.svelte
··· 5 5 let { form }: { form: ActionData } = $props(); 6 6 </script> 7 7 8 - <div class="flex min-h-full flex-col items-center justify-center px-6 py-12 lg:px-8"> 8 + <div class="flex min-h-screen flex-col items-center justify-center px-6 py-12 lg:px-8"> 9 9 <div class="sm:mx-auto sm:w-full sm:max-w-sm"> 10 10 <Logo class="mx-auto size-10" /> 11 11 <h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-white">login/register</h2>
+22
src/routes/oauth/callback/+server.ts
··· 1 + import { atclient } from '$lib/server/client'; 2 + import { error, redirect } from '@sveltejs/kit'; 3 + import type { RequestEvent } from '../../$types'; 4 + import { NYX_PASSWORD } from '$env/static/private'; 5 + import { encryptString } from '$lib/server/crypts'; 6 + import { decodeBase64, encodeBase64urlNoPadding } from '@oslojs/encoding'; 7 + 8 + export async function GET({ request, cookies }: RequestEvent) { 9 + const params = new URLSearchParams(request.url.split('?')[1]); 10 + try { 11 + const { session } = await atclient.callback(params); 12 + const key = decodeBase64(NYX_PASSWORD); 13 + const encrypted = await encryptString(key, session.did); 14 + const encoded = encodeBase64urlNoPadding(encrypted); 15 + cookies.set('sid', encoded, { path: '/', maxAge: 60 * 60, httpOnly: true, sameSite: 'lax' }); 16 + } catch (err) { 17 + console.log(err); 18 + error(500, { message: (err as Error).message }); 19 + } 20 + 21 + redirect(301, '/'); 22 + }
+7 -4
src/routes/user/[username]/+page.server.ts src/routes/user/[handle]/+page.server.ts
··· 1 + import { resolveHandle } from '$lib/bluesky.js'; 1 2 import { db } from '$lib/server/db'; 2 3 import * as table from '$lib/server/db/schema'; 3 4 import { eq } from 'drizzle-orm'; 4 5 5 6 /** @type {import('./$types').PageServerLoad} */ 6 7 export async function load(event) { 7 - const username = event.params.username; 8 + const handle = event.params.handle; 9 + 10 + const did = await resolveHandle({ handle: handle }); 8 11 9 - const movies = await db.select().from(table.items).where(eq(table.items.username, username)); 12 + const movies = await db.select().from(table.items).where(eq(table.items.did, did)); 10 13 11 14 // filter out movies that are not watched 12 15 const watchedItems = movies.filter((movie) => movie.watched === 1); ··· 15 18 watchedItems.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); 16 19 17 20 let isUser = false; 18 - if (event.locals.user?.username === username) { 21 + if (event.locals.user?.did === did) { 19 22 isUser = true; 20 23 } 21 24 22 - return { items: watchedItems, isUser, username }; 25 + return { items: watchedItems, isUser, username: event.params.handle }; 23 26 }
+2
src/routes/user/[username]/+page.svelte src/routes/user/[handle]/+page.svelte
··· 24 24 original_title: movie.originalTitle, 25 25 movieId: movie.movieId ?? undefined, 26 26 showId: movie.showId ?? undefined, 27 + rating: movie.rating ?? undefined, 28 + ratingText: movie.ratingText ?? undefined 27 29 }))} 28 30 /> 29 31 </Container>
+1 -1
static/favicon.svg
··· 1 1 <svg 2 2 xmlns="http://www.w3.org/2000/svg" 3 3 viewBox="0 0 24 24" 4 - fill="#f43f5e" 4 + fill="#0ea5e9" 5 5 width="24" 6 6 height="24" 7 7 >
+1
static/justwatch_logo.svg
··· 1 + <svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 415.77 67.38"><defs><style>.cls-1{fill:none;}.cls-2{clip-path:url(#clip-path);}.cls-3{fill:url(#Unbenannter_Verlauf);}.cls-4{clip-path:url(#clip-path-2);}.cls-5{fill:url(#Unbenannter_Verlauf_2);}.cls-6{clip-path:url(#clip-path-3);}.cls-7{fill:url(#Unbenannter_Verlauf_3);}.cls-8{clip-path:url(#clip-path-4);}.cls-9{fill:url(#Unbenannter_Verlauf_4);}.cls-10{clip-path:url(#clip-path-5);}.cls-11{fill:url(#Unbenannter_Verlauf_5);}.cls-12{clip-path:url(#clip-path-6);}.cls-13{fill:url(#Unbenannter_Verlauf_6);}.cls-14{clip-path:url(#clip-path-7);}.cls-15{fill:url(#Unbenannter_Verlauf_7);}.cls-16{clip-path:url(#clip-path-8);}.cls-17{fill:url(#Unbenannter_Verlauf_8);}.cls-18{clip-path:url(#clip-path-9);}.cls-19{fill:url(#Unbenannter_Verlauf_9);}.cls-20{clip-path:url(#clip-path-10);}.cls-21{fill:url(#Unbenannter_Verlauf_10);}</style><clipPath id="clip-path" transform="translate(-53.68 -63.74)"><path class="cls-1" d="M54,114.58v9.51s.27,4.36,4.58,2.1,8.75-4.51,8.75-4.51,1.24-.73,0-1.39-12.23-6.38-12.23-6.38a1.4,1.4,0,0,0-.47-.09c-.3,0-.64.14-.64.75m14.76-6.7c.08,2.2,0,10.92,0,10.92s-.31,2.33,1.82,1.3,9.19-4.78,10.78-5.6c2.15-1.11,0-2.13,0-2.13L70.6,106.72a2.22,2.22,0,0,0-.92-.25c-.48,0-1,.28-.92,1.4m14.72-7.49c.09,2.2,0,10.92,0,10.92s-.3,2.33,1.82,1.3,9.2-4.78,10.78-5.6c2.15-1.12,0-2.14,0-2.14L85.32,99.23A2.22,2.22,0,0,0,84.4,99c-.48,0-1,.28-.92,1.4M54,100.1c.08,2.2,0,10.92,0,10.92s-.31,2.33,1.82,1.3,9.19-4.78,10.78-5.6c2.15-1.11,0-2.14,0-2.14L55.84,98.94a2.21,2.21,0,0,0-.91-.25c-.48,0-1,.28-.92,1.4m14.77-7.48c.09,2.2,0,10.92,0,10.92s-.31,2.33,1.82,1.3,9.2-4.77,10.78-5.6c2.15-1.11,0-2.14,0-2.14L70.61,91.46a2.22,2.22,0,0,0-.91-.25c-.48,0-1,.27-.92,1.4m29.44-.29V104s-.16,1.71,1.39.92l9-4.64c4.17-2.15,0-4.24,0-4.24l-9-4.68a1.47,1.47,0,0,0-.66-.2c-.8,0-.7,1.21-.7,1.21M54,85C54.11,87.24,54,96,54,96s-.31,2.33,1.82,1.3,9.2-4.78,10.78-5.6c2.15-1.12,0-2.14,0-2.14L55.85,83.88a2.22,2.22,0,0,0-.92-.25c-.48,0-1,.27-.92,1.4m29.47-.3c.09,2.2,0,10.92,0,10.92S83.17,98,85.29,97s9.19-4.77,10.78-5.6c2.15-1.11,0-2.14,0-2.14L85.32,83.58a2.22,2.22,0,0,0-.91-.25c-.48,0-1,.27-.92,1.4M68.76,77.21c.09,2.2,0,10.92,0,10.92s-.31,2.33,1.82,1.3,9.2-4.78,10.78-5.6c2.15-1.11,0-2.14,0-2.14L70.6,76.06a2.22,2.22,0,0,0-.92-.25c-.48,0-1,.28-.92,1.4M54.06,72.07v9.51c0,1.12,1.11.66,1.11.66l12.23-6.39c1.24-.66,0-1.39,0-1.39s-4.44-2.25-8.75-4.5a4.81,4.81,0,0,0-2.17-.65c-2.23,0-2.4,2.75-2.4,2.75"/></clipPath><linearGradient id="Unbenannter_Verlauf" y1="199.85" x2="1" y2="199.85" gradientTransform="matrix(56.47, 0, 0, -56.47, 0.29, 11321.2)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fbd446"/><stop offset="0.06" stop-color="#fbd446"/><stop offset="1" stop-color="#e2b512"/></linearGradient><clipPath id="clip-path-2" transform="translate(-53.68 -63.74)"><path class="cls-1" d="M140.45,69.51V108.9a26.57,26.57,0,0,1-.4,4.73,11.87,11.87,0,0,1-1.36,3.88,7.17,7.17,0,0,1-2.6,2.63,8.16,8.16,0,0,1-4.2,1,7.55,7.55,0,0,1-5.36-2,9.41,9.41,0,0,1-2.8-5.1l-7.36,2.1a17,17,0,0,0,24,10.27,14.05,14.05,0,0,0,4.92-4.69,18,18,0,0,0,2.4-6.15,33.29,33.29,0,0,0,.64-6.31V69.51Z"/></clipPath><linearGradient id="Unbenannter_Verlauf_2" y1="199.86" x2="1" y2="199.86" gradientTransform="matrix(0, 424.84, 424.84, 0, -84775.83, 94.11)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e2b512"/><stop offset="0.5" stop-color="#fbd446"/><stop offset="1" stop-color="#e2b512"/></linearGradient><clipPath id="clip-path-3" transform="translate(-53.68 -63.74)"><path class="cls-1" d="M182.31,88.44v20.38q0,5.74-2.8,9.18a9,9,0,0,1-7.36,3.44,8.54,8.54,0,0,1-4.32-1,7.17,7.17,0,0,1-2.64-2.59,10.83,10.83,0,0,1-1.32-3.76,27.4,27.4,0,0,1-.36-4.49V88.44H156v23.77a21.29,21.29,0,0,0,.84,6.07,14.36,14.36,0,0,0,2.56,5,11.73,11.73,0,0,0,4.4,3.36,15.34,15.34,0,0,0,6.36,1.21,13.89,13.89,0,0,0,7.56-2.14,12.43,12.43,0,0,0,4.84-5.22h.16c0,.81,0,1.82.08,3s.13,2.31.24,3.28h7.12c-.11-1.24-.19-2.67-.24-4.29s-.08-2.94-.08-4V88.44Z"/></clipPath><linearGradient id="Unbenannter_Verlauf_3" y1="199.86" y2="199.86" gradientTransform="matrix(0, 425.14, 425.14, 0, -84796, 62.18)" xlink:href="#Unbenannter_Verlauf_2"/><clipPath id="clip-path-4" transform="translate(-53.68 -63.74)"><path class="cls-1" d="M205.55,88.07a14.22,14.22,0,0,0-4.56,2.1,10.88,10.88,0,0,0-3.24,3.64,10.69,10.69,0,0,0-1.24,5.3,9.2,9.2,0,0,0,1,4.49,9.51,9.51,0,0,0,2.6,3,12.93,12.93,0,0,0,3.56,1.9q2,.69,3.88,1.17a33.71,33.71,0,0,1,7.4,2.43,4.2,4.2,0,0,1,2.52,4,5.05,5.05,0,0,1-.64,2.67,4.94,4.94,0,0,1-1.72,1.7,7.76,7.76,0,0,1-2.4.93,12.91,12.91,0,0,1-2.76.29,11.64,11.64,0,0,1-5.88-1.54,12.27,12.27,0,0,1-4.2-3.8l-5.36,4.61a16.51,16.51,0,0,0,6.84,5.18,22,22,0,0,0,8.44,1.7,24.18,24.18,0,0,0,5.6-.65,13.76,13.76,0,0,0,4.84-2.14,11.19,11.19,0,0,0,3.4-3.84,11.85,11.85,0,0,0,1.28-5.74,9.44,9.44,0,0,0-1-4.37,9.6,9.6,0,0,0-2.68-3.19,15.83,15.83,0,0,0-4-2.22,40.09,40.09,0,0,0-5-1.54,26.26,26.26,0,0,1-6.48-2.14,3.84,3.84,0,0,1-2.16-3.6,4.18,4.18,0,0,1,.6-2.31,4.9,4.9,0,0,1,1.6-1.54,7.57,7.57,0,0,1,2.28-.89,11.93,11.93,0,0,1,2.56-.28,10.24,10.24,0,0,1,5.2,1.33,9.89,9.89,0,0,1,3.6,3.44l5.2-4.45A13.09,13.09,0,0,0,218.71,89a20,20,0,0,0-7.88-1.62,20.36,20.36,0,0,0-5.28.69"/></clipPath><linearGradient id="Unbenannter_Verlauf_4" y1="199.86" y2="199.86" gradientTransform="matrix(0, 424.82, 424.82, 0, -84695.09, 25.15)" xlink:href="#Unbenannter_Verlauf_2"/><clipPath id="clip-path-5" transform="translate(-53.68 -63.74)"><path class="cls-1" d="M234.45,77.44v11h-7.28v6.23h7.28v20.78q0,6,2.84,9t8.2,3a20.15,20.15,0,0,0,3.4-.28,18.87,18.87,0,0,0,3-.77l-.24-6.23a9.15,9.15,0,0,1-2.16.67,12.22,12.22,0,0,1-2.24.22,5.09,5.09,0,0,1-4.08-1.49q-1.28-1.49-1.28-5V94.66H252V88.44H241.9v-11Z"/></clipPath><linearGradient id="Unbenannter_Verlauf_5" y1="199.86" y2="199.86" gradientTransform="matrix(0, 425.23, 425.23, 0, -84746, -9.69)" xlink:href="#Unbenannter_Verlauf_2"/><clipPath id="clip-path-6" transform="translate(-53.68 -63.74)"><polygon class="cls-1" points="321.1 69.52 308.62 115.12 308.46 115.12 295.26 69.52 286.54 69.52 273.26 115.12 273.1 115.12 260.62 69.52 252.06 69.52 268.7 126.77 276.94 126.77 290.7 80.76 290.86 80.76 304.62 126.77 312.86 126.77 329.5 69.52 321.1 69.52"/></clipPath><linearGradient id="Unbenannter_Verlauf_6" y1="199.86" y2="199.86" gradientTransform="matrix(0, 424.86, 424.86, 0, -84621.33, -64.77)" xlink:href="#Unbenannter_Verlauf_2"/><clipPath id="clip-path-7" transform="translate(-53.68 -63.74)"><path class="cls-1" d="M339.46,121.47a7.22,7.22,0,0,1-2.48-1,5.65,5.65,0,0,1-1.8-1.86,5.42,5.42,0,0,1-.68-2.83,5,5,0,0,1,1.6-3.88,10.24,10.24,0,0,1,4-2.18,26.44,26.44,0,0,1,5.44-1q3-.24,5.8-.24h1.84v2a11.66,11.66,0,0,1-2.84,8q-2.84,3.2-8.12,3.19a13.73,13.73,0,0,1-2.8-.28M335.9,88.92a20.11,20.11,0,0,0-6.84,4.2l4,4.85a15.24,15.24,0,0,1,4.76-3.19,14.82,14.82,0,0,1,6-1.25,10.52,10.52,0,0,1,6.8,2.06q2.56,2.06,2.56,6.35V103q-4.8,0-9.52.4a34,34,0,0,0-8.48,1.74,14.68,14.68,0,0,0-6.08,3.88,9.86,9.86,0,0,0-2.32,6.91,10.92,10.92,0,0,0,1.32,5.58,11.17,11.17,0,0,0,3.32,3.64,13.55,13.55,0,0,0,4.44,2,19.5,19.5,0,0,0,4.68.6,15.61,15.61,0,0,0,7.48-1.7,14.63,14.63,0,0,0,5.24-4.77h.24a24,24,0,0,0,.64,5.5H361a20.88,20.88,0,0,1-.44-3.52c-.08-1.43-.12-2.79-.12-4.08V102.43a17.76,17.76,0,0,0-1-5.9,13.1,13.1,0,0,0-2.88-4.77,13.4,13.4,0,0,0-4.92-3.19,19.45,19.45,0,0,0-7.08-1.17,24.24,24.24,0,0,0-8.68,1.54"/></clipPath><linearGradient id="Unbenannter_Verlauf_7" y1="199.86" y2="199.86" gradientTransform="matrix(0, 424.96, 424.96, 0, -84587.82, -108.94)" xlink:href="#Unbenannter_Verlauf_2"/><clipPath id="clip-path-8" transform="translate(-53.68 -63.74)"><path class="cls-1" d="M371.22,77.44v11h-7.28v6.23h7.28v20.78q0,6,2.84,9t8.2,3a20.15,20.15,0,0,0,3.4-.28,18.8,18.8,0,0,0,3-.77l-.24-6.23a9.11,9.11,0,0,1-2.16.67A12.19,12.19,0,0,1,384,121a5.09,5.09,0,0,1-4.08-1.49q-1.28-1.49-1.28-5V94.66h10.08V88.44H378.66v-11Z"/></clipPath><linearGradient id="Unbenannter_Verlauf_8" y1="199.86" y2="199.86" gradientTransform="matrix(0, 425.22, 425.22, 0, -84607.3, -146.34)" xlink:href="#Unbenannter_Verlauf_2"/><clipPath id="clip-path-9" transform="translate(-53.68 -63.74)"><path class="cls-1" d="M404.22,88.84a18.6,18.6,0,0,0-10.68,10.51A21.65,21.65,0,0,0,392,107.6a21.36,21.36,0,0,0,1.56,8.33,18.84,18.84,0,0,0,4.28,6.35,19.3,19.3,0,0,0,6.44,4.09,22.15,22.15,0,0,0,8.12,1.45,20.15,20.15,0,0,0,8.16-1.58,16.31,16.31,0,0,0,6-4.41l-5-4.61a10.76,10.76,0,0,1-3.88,3.07,12.06,12.06,0,0,1-14.44-3,13.38,13.38,0,0,1-2.44-4.41,16.9,16.9,0,0,1,0-10.51,13.4,13.4,0,0,1,2.44-4.41,12,12,0,0,1,3.88-3,11.64,11.64,0,0,1,5.24-1.13A10.93,10.93,0,0,1,417.46,95a9.22,9.22,0,0,1,3.6,3.11l5.44-4.45A14.58,14.58,0,0,0,420.22,89a20.91,20.91,0,0,0-8-1.62,21.93,21.93,0,0,0-8,1.45"/></clipPath><linearGradient id="Unbenannter_Verlauf_9" y1="199.86" y2="199.86" gradientTransform="matrix(0, 424.96, 424.96, 0, -84523.11, -174.2)" xlink:href="#Unbenannter_Verlauf_2"/><clipPath id="clip-path-10" transform="translate(-53.68 -63.74)"><path class="cls-1" d="M432.18,65.63v61.14h7.52V106.42q0-5.76,2.88-9.2a9.16,9.16,0,0,1,7.36-3.45,8.32,8.32,0,0,1,4.28,1,7.32,7.32,0,0,1,2.6,2.6,10.87,10.87,0,0,1,1.32,3.73,26.67,26.67,0,0,1,.36,4.46v21.24H466V103a21.33,21.33,0,0,0-.84-6.07,13.66,13.66,0,0,0-2.6-4.93,12.87,12.87,0,0,0-4.4-3.36,14.66,14.66,0,0,0-6.32-1.25,13.75,13.75,0,0,0-7.36,2,11.81,11.81,0,0,0-4.64,4.85h-.16V65.63Z"/></clipPath><linearGradient id="Unbenannter_Verlauf_10" y1="199.86" y2="199.86" gradientTransform="matrix(0, 425.26, 425.26, 0, -84543.98, -224.95)" xlink:href="#Unbenannter_Verlauf_2"/></defs><title>JW_Logo_color</title><g class="cls-2"><rect class="cls-3" y="5.58" width="59.08" height="59.13"/></g><g class="cls-4"><rect class="cls-5" x="102.16" y="81.25" width="60.41" height="35.25" transform="translate(-27.46 161.74) rotate(-86.78)"/></g><g class="cls-6"><rect class="cls-7" x="152.45" y="89.97" width="41.24" height="36.32" transform="translate(1.71 211.12) rotate(-86.78)"/></g><g class="cls-8"><rect class="cls-9" x="188.67" y="91.29" width="42.08" height="32.62" transform="translate(36.82 247.21) rotate(-86.78)"/></g><g class="cls-10"><rect class="cls-11" x="213.93" y="88.64" width="51.29" height="27.57" transform="translate(70.18 272.14) rotate(-86.78)"/></g><g class="cls-12"><rect class="cls-13" x="260.02" y="57.87" width="61.51" height="80.54" transform="translate(122.78 319.22) rotate(-86.78)"/></g><g class="cls-14"><rect class="cls-15" x="322.79" y="89.37" width="42.21" height="36.38" transform="translate(163.52 381.14) rotate(-86.78)"/></g><g class="cls-16"><rect class="cls-17" x="350.69" y="88.64" width="51.29" height="27.57" transform="translate(199.26 408.69) rotate(-86.78)"/></g><g class="cls-18"><rect class="cls-19" x="388.15" y="89.21" width="42.31" height="36.78" transform="translate(225.21 446.48) rotate(-86.78)"/></g><g class="cls-20"><rect class="cls-21" x="417.63" y="77.59" width="62.94" height="37.22" transform="translate(274.16 475.46) rotate(-86.78)"/></g></svg>
+1
static/tmdb_logo.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 489.04 35.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="17.7" x2="489.04" y2="17.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 5</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M293.5,0h8.9l8.75,23.2h.1L320.15,0h8.35L313.9,35.4h-6.25Zm46.6,0h7.8V35.4h-7.8Zm22.2,0h24.05V7.2H370.1v6.6h15.35V21H370.1v7.2h17.15v7.2H362.3Zm55,0H429a33.54,33.54,0,0,1,8.07,1A18.55,18.55,0,0,1,443.75,4a15.1,15.1,0,0,1,4.52,5.53A18.5,18.5,0,0,1,450,17.8a16.91,16.91,0,0,1-1.63,7.58,16.37,16.37,0,0,1-4.37,5.5,19.52,19.52,0,0,1-6.35,3.37A24.59,24.59,0,0,1,430,35.4H417.29Zm7.81,28.2h4a21.57,21.57,0,0,0,5-.55,10.87,10.87,0,0,0,4-1.83,8.69,8.69,0,0,0,2.67-3.34,11.92,11.92,0,0,0,1-5.08,9.87,9.87,0,0,0-1-4.52,9,9,0,0,0-2.62-3.18,11.68,11.68,0,0,0-3.88-1.88,17.43,17.43,0,0,0-4.67-.62h-4.6ZM461.24,0h13.2a34.42,34.42,0,0,1,4.63.32,12.9,12.9,0,0,1,4.17,1.3,7.88,7.88,0,0,1,3,2.73A8.34,8.34,0,0,1,487.39,9a7.42,7.42,0,0,1-1.67,5,9.28,9.28,0,0,1-4.43,2.82v.1a10,10,0,0,1,3.18,1,8.38,8.38,0,0,1,2.45,1.85,7.79,7.79,0,0,1,1.57,2.62,9.16,9.16,0,0,1,.55,3.2,8.52,8.52,0,0,1-1.2,4.68,9.42,9.42,0,0,1-3.1,3,13.38,13.38,0,0,1-4.27,1.65,23.11,23.11,0,0,1-4.73.5h-14.5ZM469,14.15h5.65a8.16,8.16,0,0,0,1.78-.2A4.78,4.78,0,0,0,478,13.3a3.34,3.34,0,0,0,1.13-1.2,3.63,3.63,0,0,0,.42-1.8,3.22,3.22,0,0,0-.47-1.82,3.33,3.33,0,0,0-1.23-1.13,5.77,5.77,0,0,0-1.7-.58,10.79,10.79,0,0,0-1.85-.17H469Zm0,14.65h7a8.91,8.91,0,0,0,1.83-.2,4.78,4.78,0,0,0,1.67-.7,4,4,0,0,0,1.23-1.3,3.71,3.71,0,0,0,.47-2,3.13,3.13,0,0,0-.62-2A4,4,0,0,0,479,21.45,7.83,7.83,0,0,0,477,20.9a15.12,15.12,0,0,0-2.05-.15H469Zm-265,6.53H271a17.66,17.66,0,0,0,17.66-17.66h0A17.67,17.67,0,0,0,271,0H204.06A17.67,17.67,0,0,0,186.4,17.67h0A17.66,17.66,0,0,0,204.06,35.33ZM10.1,6.9H0V0H28V6.9H17.9V35.4H10.1ZM39,0h7.8V13.2H61.9V0h7.8V35.4H61.9V20.1H46.75V35.4H39ZM80.2,0h24V7.2H88v6.6h15.35V21H88v7.2h17.15v7.2h-25Zm55,0H147l8.15,23.1h.1L163.45,0H175.2V35.4h-7.8V8.25h-.1L158,35.4h-5.95l-9-27.15H143V35.4h-7.8Z"/></g></g></svg>
+1 -1
tailwind.config.ts
··· 12 12 extend: { 13 13 colors: { 14 14 base: colors.neutral, 15 - accent: colors.rose, 15 + accent: colors.sky, 16 16 17 17 tmdb_primary: '#0d253f', 18 18 tmdb_secondary: '#01b4e4',