Listen to and share the music in the Atmosphere. musicsky.up.railway.app/
nextjs atproto music typescript react
3
fork

Configure Feed

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

refactor: improve codebase architecture

Signed-off-by: mejsiejdev <mejsiejdev@gmail.com>

authored by

mejsiejdev and committed by tangled.org ba93ab1e d42d731b

+426 -248
+1 -1
AGENTS.md
··· 7 7 <!-- END:nextjs-agent-rules --> 8 8 9 9 Always check for the skills that you can leverage. 10 - After introducing changes to the code base, run `pnpm run typecheck` and `pnpm run lint`. 10 + After introducing changes to the code base, run `pnpm run knip`, `pnpm run typecheck`, `pnpm run lint`, `pnpm run build`.
+21
eslint.config.mjs
··· 2 2 import nextVitals from "eslint-config-next/core-web-vitals"; 3 3 import nextTs from "eslint-config-next/typescript"; 4 4 import eslintConfigPrettier from "eslint-config-prettier/flat"; 5 + import { flatConfigs as importXConfigs } from "eslint-plugin-import-x"; 5 6 6 7 const eslintConfig = defineConfig([ 7 8 ...nextVitals, 8 9 ...nextTs, 10 + importXConfigs.recommended, 11 + importXConfigs.typescript, 12 + { 13 + files: ["**/*.ts", "**/*.tsx"], 14 + languageOptions: { 15 + parserOptions: { 16 + projectService: true, 17 + tsconfigRootDir: import.meta.dirname, 18 + }, 19 + }, 20 + rules: { 21 + "@typescript-eslint/no-floating-promises": "error", 22 + "@typescript-eslint/no-misused-promises": "error", 23 + "@typescript-eslint/await-thenable": "error", 24 + "@typescript-eslint/no-unnecessary-condition": "error", 25 + "@typescript-eslint/switch-exhaustiveness-check": "error", 26 + "@typescript-eslint/prefer-nullish-coalescing": "error", 27 + }, 28 + }, 9 29 { 10 30 rules: { 11 31 "id-length": ["error", { min: 2, exceptions: ["_", "i", "j"] }], ··· 25 45 "no-var": "error", 26 46 "prefer-const": "error", 27 47 "no-nested-ternary": "error", 48 + "import-x/no-cycle": "error", 28 49 }, 29 50 }, 30 51 globalIgnores([
+3 -2
package.json
··· 32 32 "kysely": "^0.28.11", 33 33 "lucide-react": "^0.575.0", 34 34 "multiformats": "^13.4.2", 35 - "next": "^16.2.0-canary.84", 35 + "next": "16.2.0-canary.103", 36 36 "next-themes": "^0.4.6", 37 37 "radix-ui": "^1.4.3", 38 38 "react": "^19.2.4", ··· 52 52 "@types/react-dom": "^19", 53 53 "babel-plugin-react-compiler": "1.0.0", 54 54 "eslint": "^9", 55 - "eslint-config-next": "^16.1.6", 55 + "eslint-config-next": "^16.1.7", 56 56 "eslint-config-prettier": "^10.1.8", 57 + "eslint-plugin-import-x": "^4.16.2", 57 58 "husky": "^9.1.7", 58 59 "knip": "^5.86.0", 59 60 "lint-staged": "^16.2.7",
+137 -53
pnpm-lock.yaml
··· 50 50 specifier: ^13.4.2 51 51 version: 13.4.2 52 52 next: 53 - specifier: ^16.2.0-canary.84 54 - version: 16.2.0-canary.84(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 53 + specifier: 16.2.0-canary.103 54 + version: 16.2.0-canary.103(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 55 55 next-themes: 56 56 specifier: ^0.4.6 57 57 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ··· 105 105 specifier: ^9 106 106 version: 9.39.3(jiti@2.6.1) 107 107 eslint-config-next: 108 - specifier: ^16.1.6 109 - version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) 108 + specifier: ^16.1.7 109 + version: 16.1.7(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) 110 110 eslint-config-prettier: 111 111 specifier: ^10.1.8 112 112 version: 10.1.8(eslint@9.39.3(jiti@2.6.1)) 113 + eslint-plugin-import-x: 114 + specifier: ^4.16.2 115 + version: 4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)) 113 116 husky: 114 117 specifier: ^9.1.7 115 118 version: 9.1.7 ··· 1268 1271 integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==, 1269 1272 } 1270 1273 1271 - "@next/env@16.2.0-canary.84": 1274 + "@next/env@16.2.0-canary.103": 1272 1275 resolution: 1273 1276 { 1274 - integrity: sha512-ME1MoCjBgI1UO6QUohN1n3nBBrbwQApL5K3n6EHQQ5gQ5AmsZfz+xl5Q9MSk6zwZ0Taao98FJM1PT61brTvxUw==, 1277 + integrity: sha512-skoAsK5U7T/r5wvK+jxsuFN3p2EM7niKMszOFfMJ9EGprtGL9V6AHssJCNsjV5sM4gM1j8e2VN0/qxZqYw/PEQ==, 1275 1278 } 1276 1279 1277 - "@next/eslint-plugin-next@16.1.6": 1280 + "@next/eslint-plugin-next@16.1.7": 1278 1281 resolution: 1279 1282 { 1280 - integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==, 1283 + integrity: sha512-v/bRGOJlfRCO+NDKt0bZlIIWjhMKU8xbgEQBo+rV9C8S6czZvs96LZ/v24/GvpEnovZlL4QDpku/RzWHVbmPpA==, 1281 1284 } 1282 1285 1283 - "@next/swc-darwin-arm64@16.2.0-canary.84": 1286 + "@next/swc-darwin-arm64@16.2.0-canary.103": 1284 1287 resolution: 1285 1288 { 1286 - integrity: sha512-ba3YyrFdRCKxZJZs3dp/w16wc2uihL+7v/gPvOySqVUZP7Py2uKW0NBp+vVPkPOqgnO4xIgCkLKkv4cFfrrctw==, 1289 + integrity: sha512-rG2Qdfc//ABJBOuT/vhWDwFTytEAwzYg3ahoR/hmYV0xNzdOD7+708iGEvgqjJDAuPr2oNPa5OsKQMbxZOvO1Q==, 1287 1290 } 1288 1291 engines: { node: ">= 10" } 1289 1292 cpu: [arm64] 1290 1293 os: [darwin] 1291 1294 1292 - "@next/swc-darwin-x64@16.2.0-canary.84": 1295 + "@next/swc-darwin-x64@16.2.0-canary.103": 1293 1296 resolution: 1294 1297 { 1295 - integrity: sha512-So0htpC62A/LEDpneD9bvDzG+Uv+asu07jkeHtRf77uhRUWtrn0ERRz4zRU7Ljqs0tVEbIxm1NcksXjVD4M/Lw==, 1298 + integrity: sha512-lDKoMLHndUqTKgTX+2mUvXctgX01VCOLTDj4dCFeRCEnQ5e1kmohAudpkveSs1zzB/EfRJmxowdSJLj6Rv0dNQ==, 1296 1299 } 1297 1300 engines: { node: ">= 10" } 1298 1301 cpu: [x64] 1299 1302 os: [darwin] 1300 1303 1301 - "@next/swc-linux-arm64-gnu@16.2.0-canary.84": 1304 + "@next/swc-linux-arm64-gnu@16.2.0-canary.103": 1302 1305 resolution: 1303 1306 { 1304 - integrity: sha512-tZI3j+0dP8W3906UjkaIHWzTV7liKdwIIsCH4CHpZD9K5AExu1zegCvWS9CeZnaphxhQkIFsW7aERGn9mNO+rQ==, 1307 + integrity: sha512-imC6r1DRm08FCY7Pi8HoIiN/5LEpfmUHYJTOxeL5lGfu1RK5qcFLiopl3tK+RZD4ICjKtm6v5exOwIYJkJlAeA==, 1305 1308 } 1306 1309 engines: { node: ">= 10" } 1307 1310 cpu: [arm64] 1308 1311 os: [linux] 1309 1312 1310 - "@next/swc-linux-arm64-musl@16.2.0-canary.84": 1313 + "@next/swc-linux-arm64-musl@16.2.0-canary.103": 1311 1314 resolution: 1312 1315 { 1313 - integrity: sha512-K8duruf6LPrENRFedvk/rzwDG4aI2Lx190NTNL31ai92yM49YRIapCfQQuwkRVc9Sw4EPu7EbD3VVMoC9fp3dw==, 1316 + integrity: sha512-giiEjHAVZEPQQPh9MJZQ0u0oCJBsDP+7os7s+SF1f5ARZ2EuUkUHD9+fZFD2cPXYAta3V1VR8O48v2vQglGupQ==, 1314 1317 } 1315 1318 engines: { node: ">= 10" } 1316 1319 cpu: [arm64] 1317 1320 os: [linux] 1318 1321 1319 - "@next/swc-linux-x64-gnu@16.2.0-canary.84": 1322 + "@next/swc-linux-x64-gnu@16.2.0-canary.103": 1320 1323 resolution: 1321 1324 { 1322 - integrity: sha512-bmkVtdkwuMFMR2KDjOMgBnRhBmnhjic9RiitNDBw35nbo6qMgiplW67/04aIg+wf7ZKz0IsfN779iNu9SZgLBA==, 1325 + integrity: sha512-2qS2AnlZnEYiLqV2CnUOSSIGYd6zdOFUJxNGG33Te4JCAD/XfEMEQUSttneXq2d57snUMA0VM1RZkoV+LqBioA==, 1323 1326 } 1324 1327 engines: { node: ">= 10" } 1325 1328 cpu: [x64] 1326 1329 os: [linux] 1327 1330 1328 - "@next/swc-linux-x64-musl@16.2.0-canary.84": 1331 + "@next/swc-linux-x64-musl@16.2.0-canary.103": 1329 1332 resolution: 1330 1333 { 1331 - integrity: sha512-S+2ryiAulrioaFspttGkaz0iZUpikGvu/+VgCuOmCAeiINXzE09lZVUg3fr95YgtQV5aMObvnbG5XT4JmSCDog==, 1334 + integrity: sha512-+1EKkRsut6lbZ5jrZ6+eE2iqazUySDeAkHAobsl8RKqV4Lg9TQnTGBYALj8BC0gnYKQ0fIMAgsj2jlMase7koQ==, 1332 1335 } 1333 1336 engines: { node: ">= 10" } 1334 1337 cpu: [x64] 1335 1338 os: [linux] 1336 1339 1337 - "@next/swc-win32-arm64-msvc@16.2.0-canary.84": 1340 + "@next/swc-win32-arm64-msvc@16.2.0-canary.103": 1338 1341 resolution: 1339 1342 { 1340 - integrity: sha512-VcqA/BMYLytMhNxpscxMb/oelCM1Qfh27Q4f8wuVPwRCvzi172MlTS087T1lr1+3Esxyjv8UqnZQoIh9ytgqkA==, 1343 + integrity: sha512-slY4MkhqM86r8Jfw7R7/OsCEYPguV74HFFLWLFHulrdnsQXugfksPe9JIpVIzLQ4lS0R+dcSBoZkpiJ99zvRpA==, 1341 1344 } 1342 1345 engines: { node: ">= 10" } 1343 1346 cpu: [arm64] 1344 1347 os: [win32] 1345 1348 1346 - "@next/swc-win32-x64-msvc@16.2.0-canary.84": 1349 + "@next/swc-win32-x64-msvc@16.2.0-canary.103": 1347 1350 resolution: 1348 1351 { 1349 - integrity: sha512-J4+D73k6gQhoXZ4AOXYf7bMC96TJEFN0h9ql252QwG6uaB+5R08ZAqsAVyxi/aN0tydCkatcxzfWxhDwqAbQxg==, 1352 + integrity: sha512-Bkr+ye4g1ImQFMraQ12hDoE0f1WQKTXgY7aIhAPZxzt5qwy8TyJdq16Ec2BrR9uYVGLS9ZBVwat+duE66h9hGw==, 1350 1353 } 1351 1354 engines: { node: ">= 10" } 1352 1355 cpu: [x64] ··· 1578 1581 } 1579 1582 cpu: [x64] 1580 1583 os: [win32] 1584 + 1585 + "@package-json/types@0.0.12": 1586 + resolution: 1587 + { 1588 + integrity: sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw==, 1589 + } 1581 1590 1582 1591 "@radix-ui/number@1.1.1": 1583 1592 resolution: ··· 3394 3403 } 3395 3404 engines: { node: ">=20" } 3396 3405 3406 + comment-parser@1.4.5: 3407 + resolution: 3408 + { 3409 + integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==, 3410 + } 3411 + engines: { node: ">= 12.0.0" } 3412 + 3397 3413 concat-map@0.0.1: 3398 3414 resolution: 3399 3415 { ··· 3832 3848 } 3833 3849 engines: { node: ">=10" } 3834 3850 3835 - eslint-config-next@16.1.6: 3851 + eslint-config-next@16.1.7: 3836 3852 resolution: 3837 3853 { 3838 - integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==, 3854 + integrity: sha512-FTq1i/QDltzq+zf9aB/cKWAiZ77baG0V7h8dRQh3thVx7I4dwr6ZXQrWKAaTB7x5VwVXlzoUTyMLIVQPLj2gJg==, 3839 3855 } 3840 3856 peerDependencies: 3841 3857 eslint: ">=9.0.0" ··· 3852 3868 hasBin: true 3853 3869 peerDependencies: 3854 3870 eslint: ">=7.0.0" 3871 + 3872 + eslint-import-context@0.1.9: 3873 + resolution: 3874 + { 3875 + integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==, 3876 + } 3877 + engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } 3878 + peerDependencies: 3879 + unrs-resolver: ^1.0.0 3880 + peerDependenciesMeta: 3881 + unrs-resolver: 3882 + optional: true 3855 3883 3856 3884 eslint-import-resolver-node@0.3.9: 3857 3885 resolution: ··· 3899 3927 eslint-import-resolver-webpack: 3900 3928 optional: true 3901 3929 3930 + eslint-plugin-import-x@4.16.2: 3931 + resolution: 3932 + { 3933 + integrity: sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw==, 3934 + } 3935 + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } 3936 + peerDependencies: 3937 + "@typescript-eslint/utils": ^8.56.0 3938 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 3939 + eslint-import-resolver-node: "*" 3940 + peerDependenciesMeta: 3941 + "@typescript-eslint/utils": 3942 + optional: true 3943 + eslint-import-resolver-node: 3944 + optional: true 3945 + 3902 3946 eslint-plugin-import@2.32.0: 3903 3947 resolution: 3904 3948 { ··· 5502 5546 react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc 5503 5547 react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc 5504 5548 5505 - next@16.2.0-canary.84: 5549 + next@16.2.0-canary.103: 5506 5550 resolution: 5507 5551 { 5508 - integrity: sha512-8ro2ws68hhaLRSHmjhT0P6N2L15KAZcNXHoPCtcsW42zmUlcQdinNZnvKipnrRdYET7mL6O1m0TSL1Phba3EBA==, 5552 + integrity: sha512-ypkgjRRYB0o7yvUDBILArvx6hjcAOr8OKE58qk/U73aaipq8HK8uZZ1UqxSJUcV3XqVvFNiYXiBgY0VwNKX7gA==, 5509 5553 } 5510 5554 engines: { node: ">=20.9.0" } 5511 5555 hasBin: true ··· 6386 6430 integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==, 6387 6431 } 6388 6432 engines: { node: ">=0.10.0" } 6433 + 6434 + stable-hash-x@0.2.0: 6435 + resolution: 6436 + { 6437 + integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==, 6438 + } 6439 + engines: { node: ">=12.0.0" } 6389 6440 6390 6441 stable-hash@0.0.5: 6391 6442 resolution: ··· 7929 7980 "@tybys/wasm-util": 0.10.1 7930 7981 optional: true 7931 7982 7932 - "@next/env@16.2.0-canary.84": {} 7983 + "@next/env@16.2.0-canary.103": {} 7933 7984 7934 - "@next/eslint-plugin-next@16.1.6": 7985 + "@next/eslint-plugin-next@16.1.7": 7935 7986 dependencies: 7936 7987 fast-glob: 3.3.1 7937 7988 7938 - "@next/swc-darwin-arm64@16.2.0-canary.84": 7989 + "@next/swc-darwin-arm64@16.2.0-canary.103": 7939 7990 optional: true 7940 7991 7941 - "@next/swc-darwin-x64@16.2.0-canary.84": 7992 + "@next/swc-darwin-x64@16.2.0-canary.103": 7942 7993 optional: true 7943 7994 7944 - "@next/swc-linux-arm64-gnu@16.2.0-canary.84": 7995 + "@next/swc-linux-arm64-gnu@16.2.0-canary.103": 7945 7996 optional: true 7946 7997 7947 - "@next/swc-linux-arm64-musl@16.2.0-canary.84": 7998 + "@next/swc-linux-arm64-musl@16.2.0-canary.103": 7948 7999 optional: true 7949 8000 7950 - "@next/swc-linux-x64-gnu@16.2.0-canary.84": 8001 + "@next/swc-linux-x64-gnu@16.2.0-canary.103": 7951 8002 optional: true 7952 8003 7953 - "@next/swc-linux-x64-musl@16.2.0-canary.84": 8004 + "@next/swc-linux-x64-musl@16.2.0-canary.103": 7954 8005 optional: true 7955 8006 7956 - "@next/swc-win32-arm64-msvc@16.2.0-canary.84": 8007 + "@next/swc-win32-arm64-msvc@16.2.0-canary.103": 7957 8008 optional: true 7958 8009 7959 - "@next/swc-win32-x64-msvc@16.2.0-canary.84": 8010 + "@next/swc-win32-x64-msvc@16.2.0-canary.103": 7960 8011 optional: true 7961 8012 7962 8013 "@noble/ciphers@1.3.0": {} ··· 8051 8102 8052 8103 "@oxc-resolver/binding-win32-x64-msvc@11.19.1": 8053 8104 optional: true 8105 + 8106 + "@package-json/types@0.0.12": {} 8054 8107 8055 8108 "@radix-ui/number@1.1.1": {} 8056 8109 ··· 9351 9404 9352 9405 commander@14.0.3: {} 9353 9406 9407 + comment-parser@1.4.5: {} 9408 + 9354 9409 concat-map@0.0.1: {} 9355 9410 9356 9411 content-disposition@1.0.1: {} ··· 9648 9703 9649 9704 escape-string-regexp@4.0.0: {} 9650 9705 9651 - eslint-config-next@16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): 9706 + eslint-config-next@16.1.7(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): 9652 9707 dependencies: 9653 - "@next/eslint-plugin-next": 16.1.6 9708 + "@next/eslint-plugin-next": 16.1.7 9654 9709 eslint: 9.39.3(jiti@2.6.1) 9655 9710 eslint-import-resolver-node: 0.3.9 9656 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) 9711 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) 9657 9712 eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) 9658 9713 eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) 9659 9714 eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) ··· 9672 9727 dependencies: 9673 9728 eslint: 9.39.3(jiti@2.6.1) 9674 9729 9730 + eslint-import-context@0.1.9(unrs-resolver@1.11.1): 9731 + dependencies: 9732 + get-tsconfig: 4.13.6 9733 + stable-hash-x: 0.2.0 9734 + optionalDependencies: 9735 + unrs-resolver: 1.11.1 9736 + 9675 9737 eslint-import-resolver-node@0.3.9: 9676 9738 dependencies: 9677 9739 debug: 3.2.7 ··· 9680 9742 transitivePeerDependencies: 9681 9743 - supports-color 9682 9744 9683 - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)): 9745 + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)): 9684 9746 dependencies: 9685 9747 "@nolyfill/is-core-module": 1.0.39 9686 9748 debug: 4.4.3 ··· 9692 9754 unrs-resolver: 1.11.1 9693 9755 optionalDependencies: 9694 9756 eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) 9757 + eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)) 9695 9758 transitivePeerDependencies: 9696 9759 - supports-color 9697 9760 ··· 9702 9765 "@typescript-eslint/parser": 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) 9703 9766 eslint: 9.39.3(jiti@2.6.1) 9704 9767 eslint-import-resolver-node: 0.3.9 9705 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) 9768 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) 9769 + transitivePeerDependencies: 9770 + - supports-color 9771 + 9772 + eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.3(jiti@2.6.1)): 9773 + dependencies: 9774 + "@package-json/types": 0.0.12 9775 + "@typescript-eslint/types": 8.56.1 9776 + comment-parser: 1.4.5 9777 + debug: 4.4.3 9778 + eslint: 9.39.3(jiti@2.6.1) 9779 + eslint-import-context: 0.1.9(unrs-resolver@1.11.1) 9780 + is-glob: 4.0.3 9781 + minimatch: 10.2.4 9782 + semver: 7.7.4 9783 + stable-hash-x: 0.2.0 9784 + unrs-resolver: 1.11.1 9785 + optionalDependencies: 9786 + "@typescript-eslint/utils": 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) 9787 + eslint-import-resolver-node: 0.3.9 9706 9788 transitivePeerDependencies: 9707 9789 - supports-color 9708 9790 ··· 10665 10747 react: 19.2.4 10666 10748 react-dom: 19.2.4(react@19.2.4) 10667 10749 10668 - next@16.2.0-canary.84(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): 10750 + next@16.2.0-canary.103(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): 10669 10751 dependencies: 10670 - "@next/env": 16.2.0-canary.84 10752 + "@next/env": 16.2.0-canary.103 10671 10753 "@swc/helpers": 0.5.15 10672 10754 baseline-browser-mapping: 2.10.0 10673 10755 caniuse-lite: 1.0.30001774 ··· 10676 10758 react-dom: 19.2.4(react@19.2.4) 10677 10759 styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) 10678 10760 optionalDependencies: 10679 - "@next/swc-darwin-arm64": 16.2.0-canary.84 10680 - "@next/swc-darwin-x64": 16.2.0-canary.84 10681 - "@next/swc-linux-arm64-gnu": 16.2.0-canary.84 10682 - "@next/swc-linux-arm64-musl": 16.2.0-canary.84 10683 - "@next/swc-linux-x64-gnu": 16.2.0-canary.84 10684 - "@next/swc-linux-x64-musl": 16.2.0-canary.84 10685 - "@next/swc-win32-arm64-msvc": 16.2.0-canary.84 10686 - "@next/swc-win32-x64-msvc": 16.2.0-canary.84 10761 + "@next/swc-darwin-arm64": 16.2.0-canary.103 10762 + "@next/swc-darwin-x64": 16.2.0-canary.103 10763 + "@next/swc-linux-arm64-gnu": 16.2.0-canary.103 10764 + "@next/swc-linux-arm64-musl": 16.2.0-canary.103 10765 + "@next/swc-linux-x64-gnu": 16.2.0-canary.103 10766 + "@next/swc-linux-x64-musl": 16.2.0-canary.103 10767 + "@next/swc-win32-arm64-msvc": 16.2.0-canary.103 10768 + "@next/swc-win32-x64-msvc": 16.2.0-canary.103 10687 10769 babel-plugin-react-compiler: 1.0.0 10688 10770 sharp: 0.34.5 10689 10771 transitivePeerDependencies: ··· 11383 11465 source-map-js@1.2.1: {} 11384 11466 11385 11467 source-map@0.6.1: {} 11468 + 11469 + stable-hash-x@0.2.0: {} 11386 11470 11387 11471 stable-hash@0.0.5: {} 11388 11472
+4 -3
src/app/(main)/[handle]/[rkey]/song-view.tsx
··· 36 36 }) { 37 37 const { handle, rkey } = await params; 38 38 39 - const profileDid = await getDid(handle); 39 + const [profileDid, session] = await Promise.all([ 40 + getDid(handle), 41 + getSession(), 42 + ]); 40 43 if (!profileDid) { 41 44 notFound(); 42 45 } ··· 45 48 if (!pds) { 46 49 notFound(); 47 50 } 48 - 49 - const session = await getSession(); 50 51 51 52 const [song, { likedUris, repostedUris }] = await Promise.all([ 52 53 getSong(pds, profileDid, handle, rkey),
+4 -2
src/app/(main)/[handle]/likes/likes-list.tsx
··· 137 137 params: Promise<{ handle: string }>; 138 138 }) { 139 139 const { handle } = await params; 140 - const profileDid = await getDid(handle); 140 + const [profileDid, session] = await Promise.all([ 141 + getDid(handle), 142 + getSession(), 143 + ]); 141 144 if (!profileDid) { 142 145 notFound(); 143 146 } ··· 145 148 if (!profilePds) { 146 149 notFound(); 147 150 } 148 - const session = await getSession(); 149 151 const [songs, { likedUris, repostedUris }] = await Promise.all([ 150 152 getLikedSongs(profileDid, profilePds), 151 153 getUserInteractions(session),
+4 -2
src/app/(main)/[handle]/songs-list.tsx
··· 39 39 params: Promise<{ handle: string }>; 40 40 }) { 41 41 const { handle } = await params; 42 - const profileDid = await getDid(handle); 42 + const [profileDid, session] = await Promise.all([ 43 + getDid(handle), 44 + getSession(), 45 + ]); 43 46 if (!profileDid) { 44 47 notFound(); 45 48 } ··· 47 50 if (!pds) { 48 51 notFound(); 49 52 } 50 - const session = await getSession(); 51 53 const [songs, { likedUris, repostedUris }] = await Promise.all([ 52 54 getSongs(pds, profileDid, handle), 53 55 getUserInteractions(session),
+14 -12
src/app/(main)/upload/actions.ts
··· 17 17 const description = (formData.get("description") as string) || undefined; 18 18 const genre = (formData.get("genre") as string) || undefined; 19 19 const audio = formData.get("audio") as File; 20 - const coverArt = formData.get("coverArt") as File | null; 20 + const coverArt = formData.get("coverArt") as File; 21 21 const duration = Number(formData.get("duration")); 22 22 23 - if (!audio || !title || !duration || !slug) { 23 + if (!title || !slug) { 24 24 return { error: "Missing required fields." }; 25 25 } 26 26 27 - if (!coverArt || coverArt.size === 0) { 27 + if (audio.size === 0) { 28 + return { error: "Audio file is required." }; 29 + } 30 + 31 + if (coverArt.size === 0) { 28 32 return { error: "Cover art is required." }; 29 33 } 30 34 31 35 try { 32 - // Upload audio blob 33 - const { data: audioUpload } = await agent.uploadBlob(audio, { 34 - encoding: audio.type, 35 - }); 36 - 37 - // Upload cover art blob 38 - const { data: coverArtUpload } = await agent.uploadBlob(coverArt, { 39 - encoding: coverArt.type, 40 - }); 36 + // Upload audio and cover art blobs in parallel 37 + const [{ data: audioUpload }, { data: coverArtUpload }] = await Promise.all( 38 + [ 39 + agent.uploadBlob(audio, { encoding: audio.type }), 40 + agent.uploadBlob(coverArt, { encoding: coverArt.type }), 41 + ], 42 + ); 41 43 42 44 // Create the song record 43 45 await agent.com.atproto.repo.createRecord({
+7 -4
src/app/(main)/upload/page.tsx
··· 59 59 60 60 useEffect(() => { 61 61 if (!slugManuallyEdited.current) { 62 - setValue("slug", toSlug(title ?? ""), { shouldValidate: false }); 62 + setValue("slug", toSlug(title), { shouldValidate: false }); 63 63 } 64 64 }, [title, setValue]); 65 65 ··· 88 88 formData.set("duration", String(data.duration)); 89 89 90 90 const result = await uploadSong(formData); 91 - if (result?.error) { 91 + if (result.error) { 92 92 setError("root", { message: result.error }); 93 93 } 94 94 } 95 95 96 96 return ( 97 97 <fieldset disabled={isSubmitting}> 98 - <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> 98 + <form 99 + onSubmit={(event) => void handleSubmit(onSubmit)(event)} 100 + className="space-y-6" 101 + > 99 102 {errors.root && <FieldError>{errors.root.message}</FieldError>} 100 103 101 104 <Field data-invalid={!!errors.title}> ··· 155 158 id="audio" 156 159 type="file" 157 160 accept="audio/mpeg,audio/ogg,audio/wav,audio/flac,audio/aac,audio/webm" 158 - onChange={onAudioChange} 161 + onChange={(event) => void onAudioChange(event)} 159 162 /> 160 163 <FieldError>{errors.audio?.message}</FieldError> 161 164 </Field>
+5 -2
src/app/auth/login/page.tsx
··· 33 33 const data = await res.json(); 34 34 35 35 if (!res.ok) { 36 - throw new Error(data.error || "Login failed"); 36 + throw new Error(data.error ?? "Login failed"); 37 37 } 38 38 39 39 // Redirect to authorization server ··· 56 56 <ThemeToggle /> 57 57 </CardHeader> 58 58 <CardContent> 59 - <form onSubmit={handleSubmit} className="space-y-4"> 59 + <form 60 + onSubmit={(event) => void handleSubmit(event)} 61 + className="space-y-4" 62 + > 60 63 <Field data-invalid={!!error}> 61 64 <FieldLabel htmlFor="handle">Handle</FieldLabel> 62 65 <Input
+1 -2
src/components/avatar-section/avatar-section.tsx
··· 1 - import { AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 1 + import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 2 2 import { 3 3 DropdownMenu, 4 4 DropdownMenuTrigger, ··· 7 7 DropdownMenuItem, 8 8 DropdownMenuSeparator, 9 9 } from "@/components/ui/dropdown-menu"; 10 - import { Avatar } from "@/components/ui/avatar"; 11 10 import { getSession } from "@/lib/auth/session"; 12 11 import { Agent } from "@atproto/api"; 13 12 import Link from "next/link";
+1 -1
src/components/avatar-section/logout-button.tsx
··· 12 12 } 13 13 14 14 return ( 15 - <DropdownMenuItem variant="destructive" onClick={handleLogout}> 15 + <DropdownMenuItem variant="destructive" onClick={() => void handleLogout()}> 16 16 Log out 17 17 </DropdownMenuItem> 18 18 );
+1 -1
src/components/player-bar/player-bar.tsx
··· 31 31 if (!audio) return; 32 32 33 33 if (isPlaying) { 34 - audio.play(); 34 + void audio.play(); 35 35 } else { 36 36 audio.pause(); 37 37 }
+1 -1
src/components/playlist/actions.ts
··· 8 8 import { COLLECTIONS, getRkeyFromUri } from "@/lib/atproto"; 9 9 import { type ActionResult, ok, fail } from "@/lib/action-result"; 10 10 import { requireSession } from "@/lib/repo"; 11 + import { getSession } from "@/lib/auth/session"; 11 12 12 13 export async function createPlaylist( 13 14 _prevState: ActionResult<{ rkey: string; handle: string }> | null, ··· 257 258 } 258 259 259 260 export async function getUserPlaylistsAction() { 260 - const { getSession } = await import("@/lib/auth/session"); 261 261 const session = await getSession(); 262 262 if (!session) return []; 263 263 const agent = new Agent(session);
+1 -1
src/components/playlist/add-to-playlist-dialog.tsx
··· 43 43 setShowNewInput(false); 44 44 setNewName(""); 45 45 setError(null); 46 - getUserPlaylistsAction().then((result) => { 46 + void getUserPlaylistsAction().then((result) => { 47 47 setPlaylists(result); 48 48 setLoading(false); 49 49 });
+4 -1
src/components/playlist/edit-dialog.tsx
··· 111 111 {state && !state.success && ( 112 112 <p className="text-sm text-destructive">{state.error}</p> 113 113 )} 114 - <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> 114 + <form 115 + onSubmit={(event) => void handleSubmit(onSubmit)(event)} 116 + className="space-y-4" 117 + > 115 118 <Field data-invalid={!!errors.coverArt} className="w-auto"> 116 119 <FieldLabel>Cover art</FieldLabel> 117 120 <FieldDescription>
+33 -3
src/components/playlist/playlist-menu.tsx
··· 1 1 "use client"; 2 2 3 - import { EllipsisIcon } from "lucide-react"; 3 + import dynamic from "next/dynamic"; 4 + import { EllipsisIcon, PencilIcon, TrashIcon } from "lucide-react"; 4 5 import { 5 6 DropdownMenu, 6 7 DropdownMenuContent, 8 + DropdownMenuItem, 7 9 DropdownMenuTrigger, 8 10 } from "@/components/ui/dropdown-menu"; 9 - import { EditPlaylistDialog } from "./edit-dialog"; 10 - import { DeletePlaylistDialog } from "./delete-dialog"; 11 + 12 + const EditPlaylistDialog = dynamic( 13 + () => 14 + import("./edit-dialog").then((mod) => ({ 15 + default: mod.EditPlaylistDialog, 16 + })), 17 + { 18 + loading: () => ( 19 + <DropdownMenuItem disabled> 20 + <PencilIcon /> 21 + Edit 22 + </DropdownMenuItem> 23 + ), 24 + }, 25 + ); 26 + 27 + const DeletePlaylistDialog = dynamic( 28 + () => 29 + import("./delete-dialog").then((mod) => ({ 30 + default: mod.DeletePlaylistDialog, 31 + })), 32 + { 33 + loading: () => ( 34 + <DropdownMenuItem disabled variant="destructive"> 35 + <TrashIcon /> 36 + Delete 37 + </DropdownMenuItem> 38 + ), 39 + }, 40 + ); 11 41 12 42 export function PlaylistMenu({ 13 43 rkey,
+66
src/components/playlist/remove-from-playlist-item.tsx
··· 1 + "use client"; 2 + 3 + import { startTransition, useActionState } from "react"; 4 + import { ListXIcon } from "lucide-react"; 5 + import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; 6 + import { 7 + Tooltip, 8 + TooltipContent, 9 + TooltipTrigger, 10 + } from "@/components/ui/tooltip"; 11 + import { removeTrackFromPlaylist } from "@/components/playlist/actions"; 12 + import type { ActionResult } from "@/lib/action-result"; 13 + import { toast } from "sonner"; 14 + 15 + export function RemoveFromPlaylistItem({ 16 + playlistRkey, 17 + trackUri, 18 + isLastTrack, 19 + }: { 20 + playlistRkey: string; 21 + trackUri: string; 22 + isLastTrack?: boolean; 23 + }) { 24 + const [, removeAction] = useActionState( 25 + async (prevState: ActionResult | null, formData: FormData) => { 26 + const result = await removeTrackFromPlaylist(prevState, formData); 27 + if (!result.success) { 28 + toast.error(result.error); 29 + } else { 30 + toast.success("Removed from playlist"); 31 + } 32 + return result; 33 + }, 34 + null, 35 + ); 36 + 37 + function handleRemove() { 38 + const formData = new FormData(); 39 + formData.set("rkey", playlistRkey); 40 + formData.set("trackUri", trackUri); 41 + startTransition(() => { 42 + removeAction(formData); 43 + }); 44 + } 45 + 46 + if (isLastTrack) { 47 + return ( 48 + <Tooltip> 49 + <TooltipTrigger asChild> 50 + <DropdownMenuItem disabled> 51 + <ListXIcon size={16} /> 52 + Remove from playlist 53 + </DropdownMenuItem> 54 + </TooltipTrigger> 55 + <TooltipContent>Delete the playlist instead</TooltipContent> 56 + </Tooltip> 57 + ); 58 + } 59 + 60 + return ( 61 + <DropdownMenuItem onClick={handleRemove}> 62 + <ListXIcon size={16} /> 63 + Remove from playlist 64 + </DropdownMenuItem> 65 + ); 66 + }
+4 -4
src/components/song/actions.ts
··· 16 16 const coverArtFile = formData.get("coverArt") as File | null; 17 17 const raw = { 18 18 title: formData.get("title") as string, 19 - description: (formData.get("description") as string) ?? undefined, 20 - genre: (formData.get("genre") as string) ?? undefined, 19 + description: (formData.get("description") as string) || undefined, 20 + genre: (formData.get("genre") as string) || undefined, 21 21 coverArt: coverArtFile && coverArtFile.size > 0 ? coverArtFile : undefined, 22 22 }; 23 23 ··· 55 55 record: { 56 56 ...existingValue, 57 57 title: parsed.data.title, 58 - description: parsed.data.description || undefined, 59 - genre: parsed.data.genre || undefined, 58 + description: parsed.data.description ?? undefined, 59 + genre: parsed.data.genre ?? undefined, 60 60 coverArt, 61 61 }, 62 62 });
+4 -1
src/components/song/edit-dialog.tsx
··· 116 116 {state && !state.success && ( 117 117 <p className="text-sm text-destructive">{state.error}</p> 118 118 )} 119 - <form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> 119 + <form 120 + onSubmit={(event) => void handleSubmit(onSubmit)(event)} 121 + className="space-y-4" 122 + > 120 123 <Field data-invalid={!!errors.coverArt} className="w-auto"> 121 124 <FieldLabel>Cover art</FieldLabel> 122 125 <FieldDescription>PNG, JPEG, or WebP. Max 10 MB.</FieldDescription>
+5 -1
src/components/song/share-popover.tsx
··· 35 35 value={shareUrl} 36 36 className="flex-1 rounded-md border px-3 py-1.5 text-sm bg-muted" 37 37 /> 38 - <Button onClick={handleCopy} variant="outline" size="icon"> 38 + <Button 39 + onClick={() => void handleCopy()} 40 + variant="outline" 41 + size="icon" 42 + > 39 43 {copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />} 40 44 </Button> 41 45 </div>
+3 -101
src/components/song/song-menu.tsx
··· 1 1 "use client"; 2 2 3 - import { startTransition, useActionState } from "react"; 4 - import { EllipsisIcon, ListXIcon } from "lucide-react"; 3 + import { EllipsisIcon } from "lucide-react"; 5 4 import { 6 5 DropdownMenu, 7 6 DropdownMenuContent, 8 - DropdownMenuItem, 9 7 DropdownMenuTrigger, 10 8 } from "@/components/ui/dropdown-menu"; 11 - import { DeleteDialog } from "./delete-dialog"; 12 - import { EditDialog } from "./edit-dialog"; 13 - import { AddToPlaylistDialog } from "@/components/playlist/add-to-playlist-dialog"; 14 - import { removeTrackFromPlaylist } from "@/components/playlist/actions"; 15 - import type { ActionResult } from "@/lib/action-result"; 16 - import { toast } from "sonner"; 17 - import { 18 - Tooltip, 19 - TooltipContent, 20 - TooltipTrigger, 21 - } from "@/components/ui/tooltip"; 22 9 23 - export function SongMenu({ 24 - isOwner, 25 - loggedIn, 26 - rkey, 27 - uri, 28 - cid, 29 - title, 30 - description, 31 - genre, 32 - coverArt, 33 - playlistRkey, 34 - isLastTrack, 35 - }: { 36 - isOwner: boolean; 37 - loggedIn: boolean; 38 - rkey: string; 39 - uri: string; 40 - cid: string | undefined; 41 - title: string; 42 - description: string | null; 43 - genre: string | null; 44 - coverArt: string; 45 - playlistRkey?: string; 46 - isLastTrack?: boolean; 47 - }) { 48 - const [, removeAction, _removePending] = useActionState( 49 - async (prevState: ActionResult | null, formData: FormData) => { 50 - const result = await removeTrackFromPlaylist(prevState, formData); 51 - if (!result.success) { 52 - toast.error(result.error); 53 - } else { 54 - toast.success("Removed from playlist"); 55 - } 56 - return result; 57 - }, 58 - null, 59 - ); 60 - 61 - const hasPlaylistActions = loggedIn && !!cid; 62 - if (!isOwner && !hasPlaylistActions && !playlistRkey) { 63 - return null; 64 - } 65 - 66 - function handleRemoveFromPlaylist() { 67 - if (!playlistRkey) return; 68 - const formData = new FormData(); 69 - formData.set("rkey", playlistRkey); 70 - formData.set("trackUri", uri); 71 - startTransition(() => { 72 - removeAction(formData); 73 - }); 74 - } 75 - 10 + export function SongMenu({ children }: { children: React.ReactNode }) { 76 11 return ( 77 12 <DropdownMenu> 78 13 <DropdownMenuTrigger asChild> ··· 84 19 <EllipsisIcon size={18} /> 85 20 </button> 86 21 </DropdownMenuTrigger> 87 - <DropdownMenuContent align="end"> 88 - {loggedIn && cid && ( 89 - <AddToPlaylistDialog trackUri={uri} trackCid={cid} /> 90 - )} 91 - {playlistRkey && 92 - (isLastTrack ? ( 93 - <Tooltip> 94 - <TooltipTrigger asChild> 95 - <DropdownMenuItem disabled> 96 - <ListXIcon size={16} /> 97 - Remove from playlist 98 - </DropdownMenuItem> 99 - </TooltipTrigger> 100 - <TooltipContent>Delete the playlist instead</TooltipContent> 101 - </Tooltip> 102 - ) : ( 103 - <DropdownMenuItem onClick={handleRemoveFromPlaylist}> 104 - <ListXIcon size={16} /> 105 - Remove from playlist 106 - </DropdownMenuItem> 107 - ))} 108 - {isOwner && ( 109 - <> 110 - <EditDialog 111 - rkey={rkey} 112 - title={title} 113 - description={description} 114 - genre={genre} 115 - coverArt={coverArt} 116 - /> 117 - <DeleteDialog rkey={rkey} /> 118 - </> 119 - )} 120 - </DropdownMenuContent> 22 + <DropdownMenuContent align="end">{children}</DropdownMenuContent> 121 23 </DropdownMenu> 122 24 ); 123 25 }
+78 -14
src/components/song/song.tsx
··· 1 1 "use client"; 2 2 3 3 import Image from "next/image"; 4 - import { RepeatIcon, HeartIcon, PlayIcon, PauseIcon } from "lucide-react"; 4 + import dynamic from "next/dynamic"; 5 + import { 6 + RepeatIcon, 7 + HeartIcon, 8 + PlayIcon, 9 + PauseIcon, 10 + ListMusicIcon, 11 + PencilIcon, 12 + TrashIcon, 13 + } from "lucide-react"; 5 14 import { usePlayerStore } from "@/stores/player-store"; 6 15 import { useInteraction } from "@/hooks/use-interaction"; 7 16 import { cn } from "@/lib/utils"; 8 17 import { PUBLIC_URL } from "@/lib/api"; 9 18 import { SharePopover } from "./share-popover"; 10 19 import { SongMenu } from "./song-menu"; 20 + import { RemoveFromPlaylistItem } from "@/components/playlist/remove-from-playlist-item"; 11 21 import type { SongProps } from "@/types/song"; 12 22 import { Button } from "../ui/button"; 23 + import { DropdownMenuItem } from "../ui/dropdown-menu"; 13 24 import Link from "next/link"; 14 25 import { Badge } from "../ui/badge"; 15 26 import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; 16 27 import { formatDistanceToNow, format } from "date-fns"; 28 + 29 + const AddToPlaylistDialog = dynamic( 30 + () => 31 + import("@/components/playlist/add-to-playlist-dialog").then((mod) => ({ 32 + default: mod.AddToPlaylistDialog, 33 + })), 34 + { 35 + loading: () => ( 36 + <DropdownMenuItem disabled> 37 + <ListMusicIcon /> 38 + Add to playlist 39 + </DropdownMenuItem> 40 + ), 41 + }, 42 + ); 43 + 44 + const EditDialog = dynamic( 45 + () => import("./edit-dialog").then((mod) => ({ default: mod.EditDialog })), 46 + { 47 + loading: () => ( 48 + <DropdownMenuItem disabled> 49 + <PencilIcon /> 50 + Edit 51 + </DropdownMenuItem> 52 + ), 53 + }, 54 + ); 55 + 56 + const DeleteDialog = dynamic( 57 + () => 58 + import("./delete-dialog").then((mod) => ({ default: mod.DeleteDialog })), 59 + { 60 + loading: () => ( 61 + <DropdownMenuItem disabled variant="destructive"> 62 + <TrashIcon /> 63 + Delete 64 + </DropdownMenuItem> 65 + ), 66 + }, 67 + ); 17 68 18 69 export function Song({ 19 70 uri, ··· 154 205 </div> 155 206 <div className="flex flex-row items-center gap-4"> 156 207 <SharePopover shareUrl={shareUrl} /> 157 - <SongMenu 158 - isOwner={isOwner} 159 - loggedIn={loggedIn} 160 - rkey={rkey} 161 - uri={uri} 162 - cid={cid} 163 - title={title} 164 - description={description} 165 - genre={genre} 166 - coverArt={coverArt} 167 - playlistRkey={playlistRkey} 168 - isLastTrack={isLastTrack} 169 - /> 208 + {(isOwner || (loggedIn && !!cid) || !!playlistRkey) && ( 209 + <SongMenu> 210 + {loggedIn && cid && ( 211 + <AddToPlaylistDialog trackUri={uri} trackCid={cid} /> 212 + )} 213 + {playlistRkey && ( 214 + <RemoveFromPlaylistItem 215 + playlistRkey={playlistRkey} 216 + trackUri={uri} 217 + isLastTrack={isLastTrack} 218 + /> 219 + )} 220 + {isOwner && ( 221 + <> 222 + <EditDialog 223 + rkey={rkey} 224 + title={title} 225 + description={description} 226 + genre={genre} 227 + coverArt={coverArt} 228 + /> 229 + <DeleteDialog rkey={rkey} /> 230 + </> 231 + )} 232 + </SongMenu> 233 + )} 170 234 </div> 171 235 </div> 172 236 </div>
+1 -1
src/components/ui/field.tsx
··· 204 204 ...new Map(errors.map((error) => [error?.message, error])).values(), 205 205 ]; 206 206 207 - if (uniqueErrors?.length === 1) { 207 + if (uniqueErrors.length === 1) { 208 208 return uniqueErrors[0]?.message; 209 209 } 210 210
-9
src/lib/atproto.ts
··· 1 - export function parseAtUri(uri: string): { 2 - repo: string; 3 - collection: string; 4 - rkey: string; 5 - } { 6 - const parts = uri.split("/"); 7 - return { repo: parts[2]!, collection: parts[3]!, rkey: parts[4]! }; 8 - } 9 - 10 1 export function getDidFromUri(uri: string): string { 11 2 return uri.split("/")[2]!; 12 3 }
+1 -1
src/lib/db/index.ts
··· 1 1 import Database from "better-sqlite3"; 2 2 import { Kysely, SqliteDialect } from "kysely"; 3 3 4 - const DATABASE_PATH = process.env.DATABASE_PATH || "app.db"; 4 + const DATABASE_PATH = process.env.DATABASE_PATH ?? "app.db"; 5 5 6 6 let _db: Kysely<DatabaseSchema> | null = null; 7 7
+1 -16
src/lib/repo.ts
··· 11 11 return session; 12 12 } 13 13 14 - export async function getRecord<T>( 15 - agent: Agent, 16 - collection: string, 17 - did: string, 18 - rkey: string, 19 - ): Promise<{ value: T; cid: string; uri: string }> { 20 - const { data } = await agent.com.atproto.repo.getRecord({ 21 - repo: did, 22 - collection, 23 - rkey, 24 - }); 25 - return { value: data.value as unknown as T, cid: data.cid!, uri: data.uri }; 26 - } 27 - 28 14 interface ResolveByAuthorItem { 29 15 uri: string; 30 16 index: number; 31 - extra?: Record<string, unknown>; 32 17 } 33 18 34 19 export async function resolveRecordsByAuthor( ··· 73 58 74 59 for (let i = 0; i < authorTracks.length; i++) { 75 60 const result = songResponses[i]!; 76 - if (!result.success || !result.data) continue; 61 + if (!result.success) continue; 77 62 const value = result.data.value as unknown as TrackRecord; 78 63 const songUri = result.data.uri; 79 64 results[authorTracks[i]!.index] = {
-3
src/lib/songs.ts
··· 5 5 import type { SongProps, TrackRecord } from "@/types/song"; 6 6 import { getRkeyFromUri, buildBlobUrl, COLLECTIONS } from "@/lib/atproto"; 7 7 8 - /** @deprecated Use `getRkeyFromUri` from `@/lib/atproto` instead */ 9 - export const getRkey = getRkeyFromUri; 10 - 11 8 export function mapRecordToSong( 12 9 uri: string, 13 10 value: TrackRecord,
+1 -1
src/scripts/gen-key.ts
··· 7 7 console.log(JSON.stringify(key.privateJwk)); 8 8 } 9 9 10 - main(); 10 + void main();
+1 -1
src/scripts/migrate.ts
··· 8 8 console.log("Migrations complete."); 9 9 } 10 10 11 - main(); 11 + void main();
+18 -4
src/types/song.ts
··· 10 10 description?: string; 11 11 } 12 12 13 - export interface SongProps { 13 + export interface SongData { 14 14 uri: string; 15 15 cid?: string; 16 16 rkey: string; ··· 21 21 duration: number; 22 22 description: string | null; 23 23 author: string; 24 - isOwner: boolean; 25 - loggedIn: boolean; 24 + createdAt: string; 25 + } 26 + 27 + export interface SongInteraction { 26 28 likeRkey: string | null; 27 29 repostRkey: string | null; 28 - createdAt: string; 30 + } 31 + 32 + export interface SongContext { 33 + isOwner: boolean; 34 + loggedIn: boolean; 35 + } 36 + 37 + export interface SongPlaylistContext { 29 38 playlistRkey?: string; 30 39 isLastTrack?: boolean; 31 40 } 41 + 42 + export type SongProps = SongData & 43 + SongInteraction & 44 + SongContext & 45 + SongPlaylistContext;
+1
tsconfig.json
··· 7 7 "strict": true, 8 8 "noUncheckedIndexedAccess": true, 9 9 "noImplicitReturns": true, 10 + "noFallthroughCasesInSwitch": true, 10 11 "noEmit": true, 11 12 "esModuleInterop": true, 12 13 "module": "esnext",