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.

Squashed commit of the following:

commit e8d90e8739c0dc4d7c98185d312c12647b140f9b
Author: mejsiejdev <mejsiejdev@gmail.com>
Date: Sat Mar 7 17:21:11 2026 +0100

refactor: use song instead of track in lexicons

commit 45e306ee598281c7fa475117085946178175bedd
Author: mejsiejdev <mejsiejdev@gmail.com>
Date: Sat Mar 7 16:57:22 2026 +0100

chore: add reference to next docs for agents

commit bb89369b7cc7261be3e0df48cc6a52a552cb7e7e
Author: mejsiejdev <mejsiejdev@gmail.com>
Date: Sat Mar 7 16:57:03 2026 +0100

chore: update next

+654 -214
+7
AGENTS.md
··· 1 + <!-- BEGIN:nextjs-agent-rules --> 2 + 3 + # Next.js: ALWAYS read docs before coding 4 + 5 + Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth. 6 + 7 + <!-- END:nextjs-agent-rules -->
+1
CLAUDE.md
··· 1 + @AGENTS.md
+28 -10
lexicons/app/musicsky/temp/comment.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["text", "subject", "createdAt"], 11 + "required": [ 12 + "text", 13 + "reply", 14 + "createdAt" 15 + ], 12 16 "properties": { 13 17 "text": { 14 18 "type": "string", ··· 17 21 "maxGraphemes": 1000, 18 22 "description": "The comment text." 19 23 }, 20 - "subject": { 24 + "reply": { 21 25 "type": "ref", 22 - "ref": "com.atproto.repo.strongRef", 23 - "description": "Strong reference to the track being commented on." 24 - }, 25 - "parent": { 26 - "type": "ref", 27 - "ref": "com.atproto.repo.strongRef", 28 - "description": "Optional strong reference to a parent comment for threading." 26 + "ref": "#replyRef", 27 + "description": "Information about the comment's position in the thread." 29 28 }, 30 29 "createdAt": { 31 30 "type": "string", ··· 34 33 } 35 34 } 36 35 } 36 + }, 37 + "replyRef": { 38 + "type": "object", 39 + "required": [ 40 + "root", 41 + "parent" 42 + ], 43 + "properties": { 44 + "root": { 45 + "type": "ref", 46 + "ref": "com.atproto.repo.strongRef", 47 + "description": "Strong reference to the original track being commented on." 48 + }, 49 + "parent": { 50 + "type": "ref", 51 + "ref": "com.atproto.repo.strongRef", 52 + "description": "Strong reference to the immediate parent comment, or to the track itself if it's a top-level comment." 53 + } 54 + } 37 55 } 38 56 } 39 - } 57 + }
+23 -3
lexicons/app/musicsky/temp/playlist.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["name", "tracks", "createdAt"], 11 + "required": [ 12 + "name", 13 + "tracks", 14 + "createdAt" 15 + ], 12 16 "properties": { 13 17 "name": { 14 18 "type": "string", ··· 23 27 "maxGraphemes": 1000, 24 28 "description": "Optional description of the playlist." 25 29 }, 30 + "coverArt": { 31 + "type": "blob", 32 + "accept": [ 33 + "image/png", 34 + "image/jpeg", 35 + "image/webp" 36 + ], 37 + "maxSize": 10000000, 38 + "description": "Cover art image for the playlist. Max 10 MB." 39 + }, 26 40 "tracks": { 27 41 "type": "array", 28 42 "items": { 29 43 "type": "ref", 30 44 "ref": "com.atproto.repo.strongRef" 31 45 }, 46 + "minLength": 1, 32 47 "maxLength": 500, 33 - "description": "Ordered list of strong references to tracks. Max 500 tracks per playlist." 48 + "description": "Ordered list of strong references to tracks. Min 1, max 500 tracks per playlist." 34 49 }, 35 50 "createdAt": { 36 51 "type": "string", 37 52 "format": "datetime", 38 53 "description": "Client-declared timestamp of when the playlist was created." 54 + }, 55 + "updatedAt": { 56 + "type": "string", 57 + "format": "datetime", 58 + "description": "Client-declared timestamp of when the playlist was last updated." 39 59 } 40 60 } 41 61 } 42 62 } 43 63 } 44 - } 64 + }
+30
lexicons/app/musicsky/temp/repost.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.musicsky.temp.repost", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record representing an account reposting a track.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "subject", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "subject": { 17 + "type": "ref", 18 + "ref": "com.atproto.repo.strongRef", 19 + "description": "Strong reference to the track being reposted." 20 + }, 21 + "createdAt": { 22 + "type": "string", 23 + "format": "datetime", 24 + "description": "Client-declared timestamp of when the repost was created." 25 + } 26 + } 27 + } 28 + } 29 + } 30 + }
+99
lexicons/app/musicsky/temp/song.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.musicsky.temp.song", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record representing a music track uploaded by a user.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "title", 13 + "audio", 14 + "coverArt", 15 + "slug", 16 + "duration", 17 + "createdAt" 18 + ], 19 + "properties": { 20 + "title": { 21 + "type": "string", 22 + "minLength": 1, 23 + "maxLength": 512, 24 + "maxGraphemes": 100, 25 + "description": "Title of the track." 26 + }, 27 + "tags": { 28 + "type": "array", 29 + "items": { 30 + "type": "string", 31 + "maxLength": 128, 32 + "maxGraphemes": 64 33 + }, 34 + "maxLength": 8, 35 + "description": "List of tags associated with the track." 36 + }, 37 + "description": { 38 + "type": "string", 39 + "maxLength": 5000, 40 + "maxGraphemes": 1000, 41 + "description": "Optional description or notes about the track." 42 + }, 43 + "genre": { 44 + "type": "string", 45 + "maxLength": 256, 46 + "maxGraphemes": 64, 47 + "description": "Genre label for the track." 48 + }, 49 + "audio": { 50 + "type": "blob", 51 + "accept": [ 52 + "audio/mpeg", 53 + "audio/ogg", 54 + "audio/wav", 55 + "audio/flac", 56 + "audio/aac", 57 + "audio/webm" 58 + ], 59 + "maxSize": 52428800, 60 + "description": "The audio file blob. Max 50 MB." 61 + }, 62 + "coverArt": { 63 + "type": "blob", 64 + "accept": [ 65 + "image/png", 66 + "image/jpeg", 67 + "image/webp" 68 + ], 69 + "maxSize": 10000000, 70 + "description": "Cover art image. Max 10 MB." 71 + }, 72 + "duration": { 73 + "type": "integer", 74 + "minimum": 1, 75 + "description": "Duration of the track in seconds." 76 + }, 77 + "slug": { 78 + "type": "string", 79 + "maxLength": 128, 80 + "maxGraphemes": 128, 81 + "description": "URL-friendly slug for the track." 82 + }, 83 + "labels": { 84 + "type": "union", 85 + "refs": [ 86 + "com.atproto.label.defs#selfLabels" 87 + ], 88 + "description": "Self-label values for this track. Use for content warnings (e.g. explicit lyrics)." 89 + }, 90 + "createdAt": { 91 + "type": "string", 92 + "format": "datetime", 93 + "description": "Client-declared timestamp of when the track was uploaded." 94 + } 95 + } 96 + } 97 + } 98 + } 99 + }
-65
lexicons/app/musicsky/temp/track.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "app.musicsky.temp.track", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "description": "Record representing a music track uploaded by a user.", 8 - "key": "tid", 9 - "record": { 10 - "type": "object", 11 - "required": ["title", "audio", "duration", "createdAt"], 12 - "properties": { 13 - "title": { 14 - "type": "string", 15 - "minLength": 1, 16 - "maxLength": 512, 17 - "maxGraphemes": 100, 18 - "description": "Title of the track." 19 - }, 20 - "description": { 21 - "type": "string", 22 - "maxLength": 5000, 23 - "maxGraphemes": 1000, 24 - "description": "Optional description or notes about the track." 25 - }, 26 - "genre": { 27 - "type": "string", 28 - "maxLength": 256, 29 - "maxGraphemes": 64, 30 - "description": "Genre label for the track." 31 - }, 32 - "audio": { 33 - "type": "blob", 34 - "accept": [ 35 - "audio/mpeg", 36 - "audio/ogg", 37 - "audio/wav", 38 - "audio/flac", 39 - "audio/aac", 40 - "audio/webm" 41 - ], 42 - "maxSize": 52428800, 43 - "description": "The audio file blob. Max 50 MB." 44 - }, 45 - "coverArt": { 46 - "type": "blob", 47 - "accept": ["image/png", "image/jpeg", "image/webp"], 48 - "maxSize": 1000000, 49 - "description": "Optional cover art image. Max 1 MB." 50 - }, 51 - "duration": { 52 - "type": "integer", 53 - "minimum": 1, 54 - "description": "Duration of the track in seconds." 55 - }, 56 - "createdAt": { 57 - "type": "string", 58 - "format": "datetime", 59 - "description": "Client-declared timestamp of when the track was uploaded." 60 - } 61 - } 62 - } 63 - } 64 - } 65 - }
+52 -52
package-lock.json
··· 20 20 "kysely": "^0.28.11", 21 21 "lucide-react": "^0.575.0", 22 22 "multiformats": "^13.4.2", 23 - "next": "16.1.6", 23 + "next": "^16.2.0-canary.84", 24 24 "next-themes": "^0.4.6", 25 25 "radix-ui": "^1.4.3", 26 - "react": "19.2.3", 27 - "react-dom": "19.2.3", 26 + "react": "^19.2.4", 27 + "react-dom": "^19.2.4", 28 28 "react-hook-form": "^7.71.2", 29 29 "tailwind-merge": "^3.5.0", 30 30 "zod": "^4.3.6" ··· 37 37 "@types/react-dom": "^19", 38 38 "babel-plugin-react-compiler": "1.0.0", 39 39 "eslint": "^9", 40 - "eslint-config-next": "16.1.6", 40 + "eslint-config-next": "^16.1.6", 41 41 "eslint-config-prettier": "^10.1.8", 42 42 "husky": "^9.1.7", 43 43 "lint-staged": "^16.2.7", ··· 2614 2614 } 2615 2615 }, 2616 2616 "node_modules/@next/env": { 2617 - "version": "16.1.6", 2618 - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", 2619 - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", 2617 + "version": "16.2.0-canary.84", 2618 + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.0-canary.84.tgz", 2619 + "integrity": "sha512-ME1MoCjBgI1UO6QUohN1n3nBBrbwQApL5K3n6EHQQ5gQ5AmsZfz+xl5Q9MSk6zwZ0Taao98FJM1PT61brTvxUw==", 2620 2620 "license": "MIT" 2621 2621 }, 2622 2622 "node_modules/@next/eslint-plugin-next": { ··· 2630 2630 } 2631 2631 }, 2632 2632 "node_modules/@next/swc-darwin-arm64": { 2633 - "version": "16.1.6", 2634 - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", 2635 - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", 2633 + "version": "16.2.0-canary.84", 2634 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.0-canary.84.tgz", 2635 + "integrity": "sha512-ba3YyrFdRCKxZJZs3dp/w16wc2uihL+7v/gPvOySqVUZP7Py2uKW0NBp+vVPkPOqgnO4xIgCkLKkv4cFfrrctw==", 2636 2636 "cpu": [ 2637 2637 "arm64" 2638 2638 ], ··· 2646 2646 } 2647 2647 }, 2648 2648 "node_modules/@next/swc-darwin-x64": { 2649 - "version": "16.1.6", 2650 - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", 2651 - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", 2649 + "version": "16.2.0-canary.84", 2650 + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.0-canary.84.tgz", 2651 + "integrity": "sha512-So0htpC62A/LEDpneD9bvDzG+Uv+asu07jkeHtRf77uhRUWtrn0ERRz4zRU7Ljqs0tVEbIxm1NcksXjVD4M/Lw==", 2652 2652 "cpu": [ 2653 2653 "x64" 2654 2654 ], ··· 2662 2662 } 2663 2663 }, 2664 2664 "node_modules/@next/swc-linux-arm64-gnu": { 2665 - "version": "16.1.6", 2666 - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", 2667 - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", 2665 + "version": "16.2.0-canary.84", 2666 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.0-canary.84.tgz", 2667 + "integrity": "sha512-tZI3j+0dP8W3906UjkaIHWzTV7liKdwIIsCH4CHpZD9K5AExu1zegCvWS9CeZnaphxhQkIFsW7aERGn9mNO+rQ==", 2668 2668 "cpu": [ 2669 2669 "arm64" 2670 2670 ], ··· 2678 2678 } 2679 2679 }, 2680 2680 "node_modules/@next/swc-linux-arm64-musl": { 2681 - "version": "16.1.6", 2682 - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", 2683 - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", 2681 + "version": "16.2.0-canary.84", 2682 + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.0-canary.84.tgz", 2683 + "integrity": "sha512-K8duruf6LPrENRFedvk/rzwDG4aI2Lx190NTNL31ai92yM49YRIapCfQQuwkRVc9Sw4EPu7EbD3VVMoC9fp3dw==", 2684 2684 "cpu": [ 2685 2685 "arm64" 2686 2686 ], ··· 2694 2694 } 2695 2695 }, 2696 2696 "node_modules/@next/swc-linux-x64-gnu": { 2697 - "version": "16.1.6", 2698 - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", 2699 - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", 2697 + "version": "16.2.0-canary.84", 2698 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.0-canary.84.tgz", 2699 + "integrity": "sha512-bmkVtdkwuMFMR2KDjOMgBnRhBmnhjic9RiitNDBw35nbo6qMgiplW67/04aIg+wf7ZKz0IsfN779iNu9SZgLBA==", 2700 2700 "cpu": [ 2701 2701 "x64" 2702 2702 ], ··· 2710 2710 } 2711 2711 }, 2712 2712 "node_modules/@next/swc-linux-x64-musl": { 2713 - "version": "16.1.6", 2714 - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", 2715 - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", 2713 + "version": "16.2.0-canary.84", 2714 + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.0-canary.84.tgz", 2715 + "integrity": "sha512-S+2ryiAulrioaFspttGkaz0iZUpikGvu/+VgCuOmCAeiINXzE09lZVUg3fr95YgtQV5aMObvnbG5XT4JmSCDog==", 2716 2716 "cpu": [ 2717 2717 "x64" 2718 2718 ], ··· 2726 2726 } 2727 2727 }, 2728 2728 "node_modules/@next/swc-win32-arm64-msvc": { 2729 - "version": "16.1.6", 2730 - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", 2731 - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", 2729 + "version": "16.2.0-canary.84", 2730 + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.0-canary.84.tgz", 2731 + "integrity": "sha512-VcqA/BMYLytMhNxpscxMb/oelCM1Qfh27Q4f8wuVPwRCvzi172MlTS087T1lr1+3Esxyjv8UqnZQoIh9ytgqkA==", 2732 2732 "cpu": [ 2733 2733 "arm64" 2734 2734 ], ··· 2742 2742 } 2743 2743 }, 2744 2744 "node_modules/@next/swc-win32-x64-msvc": { 2745 - "version": "16.1.6", 2746 - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", 2747 - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", 2745 + "version": "16.2.0-canary.84", 2746 + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.0-canary.84.tgz", 2747 + "integrity": "sha512-J4+D73k6gQhoXZ4AOXYf7bMC96TJEFN0h9ql252QwG6uaB+5R08ZAqsAVyxi/aN0tydCkatcxzfWxhDwqAbQxg==", 2748 2748 "cpu": [ 2749 2749 "x64" 2750 2750 ], ··· 10295 10295 } 10296 10296 }, 10297 10297 "node_modules/next": { 10298 - "version": "16.1.6", 10299 - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", 10300 - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", 10298 + "version": "16.2.0-canary.84", 10299 + "resolved": "https://registry.npmjs.org/next/-/next-16.2.0-canary.84.tgz", 10300 + "integrity": "sha512-8ro2ws68hhaLRSHmjhT0P6N2L15KAZcNXHoPCtcsW42zmUlcQdinNZnvKipnrRdYET7mL6O1m0TSL1Phba3EBA==", 10301 10301 "license": "MIT", 10302 10302 "dependencies": { 10303 - "@next/env": "16.1.6", 10303 + "@next/env": "16.2.0-canary.84", 10304 10304 "@swc/helpers": "0.5.15", 10305 - "baseline-browser-mapping": "^2.8.3", 10305 + "baseline-browser-mapping": "^2.9.19", 10306 10306 "caniuse-lite": "^1.0.30001579", 10307 10307 "postcss": "8.4.31", 10308 10308 "styled-jsx": "5.1.6" ··· 10314 10314 "node": ">=20.9.0" 10315 10315 }, 10316 10316 "optionalDependencies": { 10317 - "@next/swc-darwin-arm64": "16.1.6", 10318 - "@next/swc-darwin-x64": "16.1.6", 10319 - "@next/swc-linux-arm64-gnu": "16.1.6", 10320 - "@next/swc-linux-arm64-musl": "16.1.6", 10321 - "@next/swc-linux-x64-gnu": "16.1.6", 10322 - "@next/swc-linux-x64-musl": "16.1.6", 10323 - "@next/swc-win32-arm64-msvc": "16.1.6", 10324 - "@next/swc-win32-x64-msvc": "16.1.6", 10325 - "sharp": "^0.34.4" 10317 + "@next/swc-darwin-arm64": "16.2.0-canary.84", 10318 + "@next/swc-darwin-x64": "16.2.0-canary.84", 10319 + "@next/swc-linux-arm64-gnu": "16.2.0-canary.84", 10320 + "@next/swc-linux-arm64-musl": "16.2.0-canary.84", 10321 + "@next/swc-linux-x64-gnu": "16.2.0-canary.84", 10322 + "@next/swc-linux-x64-musl": "16.2.0-canary.84", 10323 + "@next/swc-win32-arm64-msvc": "16.2.0-canary.84", 10324 + "@next/swc-win32-x64-msvc": "16.2.0-canary.84", 10325 + "sharp": "^0.34.5" 10326 10326 }, 10327 10327 "peerDependencies": { 10328 10328 "@opentelemetry/api": "^1.1.0", ··· 11324 11324 } 11325 11325 }, 11326 11326 "node_modules/react": { 11327 - "version": "19.2.3", 11328 - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", 11329 - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", 11327 + "version": "19.2.4", 11328 + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", 11329 + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", 11330 11330 "license": "MIT", 11331 11331 "engines": { 11332 11332 "node": ">=0.10.0" 11333 11333 } 11334 11334 }, 11335 11335 "node_modules/react-dom": { 11336 - "version": "19.2.3", 11337 - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", 11338 - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", 11336 + "version": "19.2.4", 11337 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", 11338 + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", 11339 11339 "license": "MIT", 11340 11340 "dependencies": { 11341 11341 "scheduler": "^0.27.0" 11342 11342 }, 11343 11343 "peerDependencies": { 11344 - "react": "^19.2.3" 11344 + "react": "^19.2.4" 11345 11345 } 11346 11346 }, 11347 11347 "node_modules/react-hook-form": {
+4 -4
package.json
··· 28 28 "kysely": "^0.28.11", 29 29 "lucide-react": "^0.575.0", 30 30 "multiformats": "^13.4.2", 31 - "next": "16.1.6", 31 + "next": "^16.2.0-canary.84", 32 32 "next-themes": "^0.4.6", 33 33 "radix-ui": "^1.4.3", 34 - "react": "19.2.3", 35 - "react-dom": "19.2.3", 34 + "react": "^19.2.4", 35 + "react-dom": "^19.2.4", 36 36 "react-hook-form": "^7.71.2", 37 37 "tailwind-merge": "^3.5.0", 38 38 "zod": "^4.3.6" ··· 45 45 "@types/react-dom": "^19", 46 46 "babel-plugin-react-compiler": "1.0.0", 47 47 "eslint": "^9", 48 - "eslint-config-next": "16.1.6", 48 + "eslint-config-next": "^16.1.6", 49 49 "eslint-config-prettier": "^10.1.8", 50 50 "husky": "^9.1.7", 51 51 "lint-staged": "^16.2.7",
+6 -4
src/app/(main)/[handle]/songs-list.tsx
··· 3 3 import { IdResolver } from "@atproto/identity"; 4 4 import { cacheTag } from "next/cache"; 5 5 import { notFound } from "next/navigation"; 6 - import { type Record as TrackRecord } from "@/lib/lexicons/types/app/musicsky/temp/track"; 6 + import { type Record as TrackRecord } from "@/lib/lexicons/types/app/musicsky/temp/song"; 7 7 8 8 async function getDid(handle: string) { 9 9 const agent = new Agent("https://public.api.bsky.app"); ··· 35 35 } 36 36 } 37 37 38 - async function getSongs(pds: string, did: string) { 38 + async function getSongs(pds: string, did: string, handle: string) { 39 39 "use cache"; 40 40 cacheTag("songs"); 41 41 try { ··· 43 43 44 44 const { data } = await agent.com.atproto.repo.listRecords({ 45 45 repo: did, 46 - collection: "app.musicsky.temp.track", 46 + collection: "app.musicsky.temp.song", 47 47 limit: 50, 48 48 }); 49 49 return data.records.map((record) => { ··· 51 51 return { 52 52 rkey: record.uri.split("/")[4]!, 53 53 title: value.title, 54 + slug: value.slug, 54 55 coverArt: value.coverArt 55 56 ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${value.coverArt?.ref?.toString()}` 56 57 : null, ··· 58 59 genre: value.genre ?? null, 59 60 duration: value.duration, 60 61 description: value.description ?? null, 62 + author: handle, 61 63 isOwner: did === record.uri.split("/")[2], 62 64 }; 63 65 }); ··· 81 83 if (!pds) { 82 84 notFound(); 83 85 } 84 - const songs = await getSongs(pds, did); 86 + const songs = await getSongs(pds, did, handle); 85 87 return songs.map((song) => <Song key={song.title} {...song} />); 86 88 }
+15 -11
src/app/(main)/upload/actions.ts
··· 13 13 const agent = new Agent(session); 14 14 15 15 const title = formData.get("title") as string; 16 + const slug = formData.get("slug") as string; 16 17 const description = (formData.get("description") as string) || undefined; 17 18 const genre = (formData.get("genre") as string) || undefined; 18 19 const audio = formData.get("audio") as File; 19 20 const coverArt = formData.get("coverArt") as File | null; 20 21 const duration = Number(formData.get("duration")); 21 22 22 - if (!audio || !title || !duration) { 23 + if (!audio || !title || !duration || !slug) { 23 24 return { error: "Missing required fields." }; 25 + } 26 + 27 + if (!coverArt || coverArt.size === 0) { 28 + return { error: "Cover art is required." }; 24 29 } 25 30 26 31 try { ··· 29 34 encoding: audio.type, 30 35 }); 31 36 32 - // Upload cover art blob (if provided) 33 - const coverArtBlob = 34 - coverArt && coverArt.size > 0 35 - ? (await agent.uploadBlob(coverArt, { encoding: coverArt.type })).data 36 - .blob 37 - : undefined; 37 + // Upload cover art blob 38 + const { data: coverArtUpload } = await agent.uploadBlob(coverArt, { 39 + encoding: coverArt.type, 40 + }); 38 41 39 - // Create the track record 42 + // Create the song record 40 43 await agent.com.atproto.repo.createRecord({ 41 44 repo: agent.assertDid, 42 - collection: "app.musicsky.temp.track", 45 + collection: "app.musicsky.temp.song", 43 46 record: { 44 - $type: "app.musicsky.temp.track", 47 + $type: "app.musicsky.temp.song", 45 48 title, 49 + slug, 46 50 description, 47 51 genre, 48 52 audio: audioUpload.blob, 49 - coverArt: coverArtBlob, 53 + coverArt: coverArtUpload.blob, 50 54 duration, 51 55 createdAt: new Date().toISOString(), 52 56 },
+43 -4
src/app/(main)/upload/page.tsx
··· 1 1 "use client"; 2 2 3 3 import { useForm } from "react-hook-form"; 4 + import { useEffect, useRef } from "react"; 4 5 import { zodResolver } from "@hookform/resolvers/zod"; 5 6 import { songSchema, type SongFormData } from "./song-schema"; 6 7 import { uploadSong } from "./actions"; ··· 15 16 FieldError, 16 17 } from "@/components/ui/field"; 17 18 19 + function toSlug(title: string): string { 20 + return title 21 + .toLowerCase() 22 + .trim() 23 + .replace(/[^a-z0-9\s-]/g, "") 24 + .replace(/\s+/g, "-") 25 + .replace(/-+/g, "-") 26 + .replace(/^-|-$/g, ""); 27 + } 28 + 18 29 function getAudioDuration(file: File): Promise<number> { 19 30 return new Promise((resolve, reject) => { 20 31 const audio = new Audio(); ··· 36 47 register, 37 48 handleSubmit, 38 49 setValue, 50 + watch, 39 51 setError, 40 52 formState: { errors, isSubmitting }, 41 53 } = useForm<SongFormData>({ 42 54 resolver: zodResolver(songSchema), 43 55 }); 44 56 57 + const slugManuallyEdited = useRef(false); 58 + const title = watch("title"); 59 + 60 + useEffect(() => { 61 + if (!slugManuallyEdited.current) { 62 + setValue("slug", toSlug(title ?? ""), { shouldValidate: false }); 63 + } 64 + }, [title, setValue]); 65 + 45 66 async function onAudioChange(event: React.ChangeEvent<HTMLInputElement>) { 46 67 const file = event.target.files?.[0]; 47 68 if (!file) return; ··· 59 80 async function onSubmit(data: SongFormData) { 60 81 const formData = new FormData(); 61 82 formData.set("title", data.title); 83 + formData.set("slug", data.slug); 62 84 if (data.description) formData.set("description", data.description); 63 85 if (data.genre) formData.set("genre", data.genre); 64 86 formData.set("audio", data.audio); 65 - if (data.coverArt) formData.set("coverArt", data.coverArt); 87 + formData.set("coverArt", data.coverArt); 66 88 formData.set("duration", String(data.duration)); 67 89 68 90 const result = await uploadSong(formData); ··· 86 108 <FieldError>{errors.title?.message}</FieldError> 87 109 </Field> 88 110 111 + <Field data-invalid={!!errors.slug}> 112 + <FieldLabel htmlFor="slug">Slug</FieldLabel> 113 + <FieldDescription> 114 + Used in the song&apos;s URL. Lowercase letters, numbers, and hyphens 115 + only. 116 + </FieldDescription> 117 + <Input 118 + id="slug" 119 + placeholder="my-song-title" 120 + {...register("slug", { 121 + onChange: () => { 122 + slugManuallyEdited.current = true; 123 + }, 124 + })} 125 + /> 126 + <FieldError>{errors.slug?.message}</FieldError> 127 + </Field> 128 + 89 129 <Field data-invalid={!!errors.description}> 90 130 <FieldLabel htmlFor="description">Description</FieldLabel> 91 131 <Textarea ··· 122 162 123 163 <Field data-invalid={!!errors.coverArt}> 124 164 <FieldLabel htmlFor="coverArt">Cover art</FieldLabel> 125 - <FieldDescription> 126 - PNG, JPEG, or WebP. Max 1 MB. Optional. 127 - </FieldDescription> 165 + <FieldDescription>PNG, JPEG, or WebP. Max 10 MB.</FieldDescription> 128 166 <Input 129 167 id="coverArt" 130 168 type="file" ··· 133 171 const file = event.target.files?.[0]; 134 172 if (file) setValue("coverArt", file, { shouldValidate: true }); 135 173 }} 174 + required 136 175 /> 137 176 <FieldError>{errors.coverArt?.message}</FieldError> 138 177 </Field>
+11 -4
src/app/(main)/upload/song-schema.ts
··· 12 12 const IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const; 13 13 14 14 const MAX_AUDIO_SIZE = 52_428_800; // 50 MB 15 - const MAX_COVER_ART_SIZE = 1_000_000; // 1 MB 15 + const MAX_COVER_ART_SIZE = 10_000_000; // 10 MB 16 16 17 17 export const songSchema = z.object({ 18 18 title: z 19 19 .string() 20 20 .min(1, { error: "Title is required." }) 21 21 .max(128, { error: "Title must be 128 characters or fewer." }), 22 + slug: z 23 + .string() 24 + .min(1, { error: "Slug is required." }) 25 + .max(128, { error: "Slug must be 128 characters or fewer." }) 26 + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { 27 + error: 28 + "Slug must be lowercase letters, numbers, and hyphens only, with no leading or trailing hyphens.", 29 + }), 22 30 description: z 23 31 .string() 24 32 .max(5000, { error: "Description must be 5000 characters or fewer." }) ··· 32 40 .mime([...AUDIO_MIME_TYPES], { error: "Must be a supported audio format." }) 33 41 .max(MAX_AUDIO_SIZE, { error: "Audio file must be 50 MB or smaller." }), 34 42 coverArt: z 35 - .file() 43 + .file({ error: "Cover art is required." }) 36 44 .mime([...IMAGE_MIME_TYPES], { 37 45 error: "Cover art must be PNG, JPEG, or WebP.", 38 46 }) 39 - .max(MAX_COVER_ART_SIZE, { error: "Cover art must be 1 MB or smaller." }) 40 - .optional(), 47 + .max(MAX_COVER_ART_SIZE, { error: "Cover art must be 10 MB or smaller." }), 41 48 duration: z 42 49 .number() 43 50 .int({ error: "Duration must be a whole number." })
+1 -1
src/components/song/actions.ts
··· 18 18 try { 19 19 await agent.com.atproto.repo.deleteRecord({ 20 20 repo: agent.assertDid, 21 - collection: "app.musicsky.temp.track", 21 + collection: "app.musicsky.temp.song", 22 22 rkey, 23 23 }); 24 24 updateTag("songs");
+61 -19
src/components/song/song.tsx
··· 1 1 "use client"; 2 2 3 3 import Image from "next/image"; 4 - import { EllipsisIcon } from "lucide-react"; 4 + import { 5 + EllipsisIcon, 6 + RepeatIcon, 7 + HeartIcon, 8 + MessageSquareIcon, 9 + Share2Icon, 10 + } from "lucide-react"; 5 11 import { 6 12 DropdownMenu, 7 13 DropdownMenuContent, 8 14 DropdownMenuTrigger, 9 15 } from "@/components/ui/dropdown-menu"; 10 16 import { DeleteDialog } from "./delete-dialog"; 17 + 18 + const PUBLIC_URL = process.env.PUBLIC_URL ?? "localhost:3000"; 11 19 12 20 export function Song({ 13 21 rkey, 14 22 title, 23 + slug, 15 24 coverArt, 16 25 audio, 17 26 genre, 18 27 duration, 19 28 description, 29 + author, 20 30 isOwner, 21 31 }: { 22 32 rkey: string; 23 33 title: string; 34 + slug: string; 24 35 coverArt: string | null; 25 36 audio: string; 26 37 genre: string | null; 27 38 duration: number; 28 39 description: string | null; 40 + author: string; 29 41 isOwner: boolean; 30 42 }) { 43 + async function handleShare() { 44 + try { 45 + await navigator.clipboard.writeText(`${PUBLIC_URL}/${author}/${slug}`); 46 + // toast notification would be nice 47 + } catch (err) { 48 + console.error("Error copying link:", err); 49 + } 50 + } 51 + 31 52 return ( 32 53 <div key={title} className="flex flex-col gap-4"> 33 54 <div className="flex flex-row gap-4"> ··· 51 72 </p> 52 73 </div> 53 74 </div> 54 - <DropdownMenu> 55 - <DropdownMenuTrigger asChild> 56 - <EllipsisIcon className="cursor-pointer" /> 57 - </DropdownMenuTrigger> 58 - <DropdownMenuContent align="end"> 59 - {/* 60 - <DropdownMenuItem> 61 - <Share2Icon /> 62 - Share 63 - </DropdownMenuItem> 75 + </div> 76 + <div className="flex flex-row items-center justify-between"> 77 + <div className="flex flex-row gap-12"> 78 + <div className="flex flex-row items-center gap-2"> 79 + <MessageSquareIcon size={18} /> 80 + <p className="text-sm">6</p> 81 + </div> 82 + <div className="flex flex-row items-center gap-2"> 83 + <RepeatIcon size={18} /> 84 + <p className="text-sm">2</p> 85 + </div> 86 + <div className="flex flex-row items-center gap-2"> 87 + <HeartIcon size={18} /> 88 + <p className="text-sm">12</p> 89 + </div> 90 + </div> 91 + <div className="flex flex-row items-center gap-4"> 92 + <Share2Icon 93 + size={18} 94 + className="cursor-pointer" 95 + onClick={handleShare} 96 + role="button" 97 + aria-label="Share song" 98 + /> 99 + <DropdownMenu> 100 + <DropdownMenuTrigger asChild> 101 + <EllipsisIcon size={18} className="cursor-pointer" /> 102 + </DropdownMenuTrigger> 103 + <DropdownMenuContent align="end"> 104 + {/* 64 105 <DropdownMenuItem> 65 106 <DownloadIcon /> 66 107 Download 67 108 </DropdownMenuItem> 68 109 */} 69 - {isOwner && ( 70 - <> 71 - {/* 110 + {isOwner && ( 111 + <> 112 + {/* 72 113 <DropdownMenuItem> 73 114 <PencilIcon /> 74 115 Edit 75 116 </DropdownMenuItem> 76 117 <DropdownMenuSeparator />*/} 77 - <DeleteDialog rkey={rkey} /> 78 - </> 79 - )} 80 - </DropdownMenuContent> 81 - </DropdownMenu> 118 + <DeleteDialog rkey={rkey} /> 119 + </> 120 + )} 121 + </DropdownMenuContent> 122 + </DropdownMenu> 123 + </div> 82 124 </div> 83 125 <audio controls src={audio} /> 84 126 </div>
+101 -14
src/lib/lexicons/index.ts
··· 12 12 import * as AppMusicskyTempComment from './types/app/musicsky/temp/comment.js' 13 13 import * as AppMusicskyTempLike from './types/app/musicsky/temp/like.js' 14 14 import * as AppMusicskyTempPlaylist from './types/app/musicsky/temp/playlist.js' 15 - import * as AppMusicskyTempTrack from './types/app/musicsky/temp/track.js' 15 + import * as AppMusicskyTempRepost from './types/app/musicsky/temp/repost.js' 16 + import * as AppMusicskyTempSong from './types/app/musicsky/temp/song.js' 16 17 17 18 export * as AppMusicskyTempComment from './types/app/musicsky/temp/comment.js' 18 19 export * as AppMusicskyTempLike from './types/app/musicsky/temp/like.js' 19 20 export * as AppMusicskyTempPlaylist from './types/app/musicsky/temp/playlist.js' 20 - export * as AppMusicskyTempTrack from './types/app/musicsky/temp/track.js' 21 + export * as AppMusicskyTempRepost from './types/app/musicsky/temp/repost.js' 22 + export * as AppMusicskyTempSong from './types/app/musicsky/temp/song.js' 21 23 22 24 export class AtpBaseClient extends XrpcClient { 23 25 app: AppNS ··· 58 60 comment: AppMusicskyTempCommentRecord 59 61 like: AppMusicskyTempLikeRecord 60 62 playlist: AppMusicskyTempPlaylistRecord 61 - track: AppMusicskyTempTrackRecord 63 + repost: AppMusicskyTempRepostRecord 64 + song: AppMusicskyTempSongRecord 62 65 63 66 constructor(client: XrpcClient) { 64 67 this._client = client 65 68 this.comment = new AppMusicskyTempCommentRecord(client) 66 69 this.like = new AppMusicskyTempLikeRecord(client) 67 70 this.playlist = new AppMusicskyTempPlaylistRecord(client) 68 - this.track = new AppMusicskyTempTrackRecord(client) 71 + this.repost = new AppMusicskyTempRepostRecord(client) 72 + this.song = new AppMusicskyTempSongRecord(client) 69 73 } 70 74 } 71 75 ··· 314 318 } 315 319 } 316 320 317 - export class AppMusicskyTempTrackRecord { 321 + export class AppMusicskyTempRepostRecord { 322 + _client: XrpcClient 323 + 324 + constructor(client: XrpcClient) { 325 + this._client = client 326 + } 327 + 328 + async list( 329 + params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 330 + ): Promise<{ 331 + cursor?: string 332 + records: { uri: string; value: AppMusicskyTempRepost.Record }[] 333 + }> { 334 + const res = await this._client.call('com.atproto.repo.listRecords', { 335 + collection: 'app.musicsky.temp.repost', 336 + ...params, 337 + }) 338 + return res.data 339 + } 340 + 341 + async get( 342 + params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 343 + ): Promise<{ 344 + uri: string 345 + cid: string 346 + value: AppMusicskyTempRepost.Record 347 + }> { 348 + const res = await this._client.call('com.atproto.repo.getRecord', { 349 + collection: 'app.musicsky.temp.repost', 350 + ...params, 351 + }) 352 + return res.data 353 + } 354 + 355 + async create( 356 + params: OmitKey< 357 + ComAtprotoRepoCreateRecord.InputSchema, 358 + 'collection' | 'record' 359 + >, 360 + record: Un$Typed<AppMusicskyTempRepost.Record>, 361 + headers?: Record<string, string>, 362 + ): Promise<{ uri: string; cid: string }> { 363 + const collection = 'app.musicsky.temp.repost' 364 + const res = await this._client.call( 365 + 'com.atproto.repo.createRecord', 366 + undefined, 367 + { collection, ...params, record: { ...record, $type: collection } }, 368 + { encoding: 'application/json', headers }, 369 + ) 370 + return res.data 371 + } 372 + 373 + async put( 374 + params: OmitKey< 375 + ComAtprotoRepoPutRecord.InputSchema, 376 + 'collection' | 'record' 377 + >, 378 + record: Un$Typed<AppMusicskyTempRepost.Record>, 379 + headers?: Record<string, string>, 380 + ): Promise<{ uri: string; cid: string }> { 381 + const collection = 'app.musicsky.temp.repost' 382 + const res = await this._client.call( 383 + 'com.atproto.repo.putRecord', 384 + undefined, 385 + { collection, ...params, record: { ...record, $type: collection } }, 386 + { encoding: 'application/json', headers }, 387 + ) 388 + return res.data 389 + } 390 + 391 + async delete( 392 + params: OmitKey<ComAtprotoRepoDeleteRecord.InputSchema, 'collection'>, 393 + headers?: Record<string, string>, 394 + ): Promise<void> { 395 + await this._client.call( 396 + 'com.atproto.repo.deleteRecord', 397 + undefined, 398 + { collection: 'app.musicsky.temp.repost', ...params }, 399 + { headers }, 400 + ) 401 + } 402 + } 403 + 404 + export class AppMusicskyTempSongRecord { 318 405 _client: XrpcClient 319 406 320 407 constructor(client: XrpcClient) { ··· 325 412 params: OmitKey<ComAtprotoRepoListRecords.QueryParams, 'collection'>, 326 413 ): Promise<{ 327 414 cursor?: string 328 - records: { uri: string; value: AppMusicskyTempTrack.Record }[] 415 + records: { uri: string; value: AppMusicskyTempSong.Record }[] 329 416 }> { 330 417 const res = await this._client.call('com.atproto.repo.listRecords', { 331 - collection: 'app.musicsky.temp.track', 418 + collection: 'app.musicsky.temp.song', 332 419 ...params, 333 420 }) 334 421 return res.data ··· 336 423 337 424 async get( 338 425 params: OmitKey<ComAtprotoRepoGetRecord.QueryParams, 'collection'>, 339 - ): Promise<{ uri: string; cid: string; value: AppMusicskyTempTrack.Record }> { 426 + ): Promise<{ uri: string; cid: string; value: AppMusicskyTempSong.Record }> { 340 427 const res = await this._client.call('com.atproto.repo.getRecord', { 341 - collection: 'app.musicsky.temp.track', 428 + collection: 'app.musicsky.temp.song', 342 429 ...params, 343 430 }) 344 431 return res.data ··· 349 436 ComAtprotoRepoCreateRecord.InputSchema, 350 437 'collection' | 'record' 351 438 >, 352 - record: Un$Typed<AppMusicskyTempTrack.Record>, 439 + record: Un$Typed<AppMusicskyTempSong.Record>, 353 440 headers?: Record<string, string>, 354 441 ): Promise<{ uri: string; cid: string }> { 355 - const collection = 'app.musicsky.temp.track' 442 + const collection = 'app.musicsky.temp.song' 356 443 const res = await this._client.call( 357 444 'com.atproto.repo.createRecord', 358 445 undefined, ··· 367 454 ComAtprotoRepoPutRecord.InputSchema, 368 455 'collection' | 'record' 369 456 >, 370 - record: Un$Typed<AppMusicskyTempTrack.Record>, 457 + record: Un$Typed<AppMusicskyTempSong.Record>, 371 458 headers?: Record<string, string>, 372 459 ): Promise<{ uri: string; cid: string }> { 373 - const collection = 'app.musicsky.temp.track' 460 + const collection = 'app.musicsky.temp.song' 374 461 const res = await this._client.call( 375 462 'com.atproto.repo.putRecord', 376 463 undefined, ··· 387 474 await this._client.call( 388 475 'com.atproto.repo.deleteRecord', 389 476 undefined, 390 - { collection: 'app.musicsky.temp.track', ...params }, 477 + { collection: 'app.musicsky.temp.song', ...params }, 391 478 { headers }, 392 479 ) 393 480 }
+100 -16
src/lib/lexicons/lexicons.ts
··· 21 21 key: 'tid', 22 22 record: { 23 23 type: 'object', 24 - required: ['text', 'subject', 'createdAt'], 24 + required: ['text', 'reply', 'createdAt'], 25 25 properties: { 26 26 text: { 27 27 type: 'string', ··· 30 30 maxGraphemes: 1000, 31 31 description: 'The comment text.', 32 32 }, 33 - subject: { 33 + reply: { 34 34 type: 'ref', 35 - ref: 'lex:com.atproto.repo.strongRef', 36 - description: 'Strong reference to the track being commented on.', 37 - }, 38 - parent: { 39 - type: 'ref', 40 - ref: 'lex:com.atproto.repo.strongRef', 35 + ref: 'lex:app.musicsky.temp.comment#replyRef', 41 36 description: 42 - 'Optional strong reference to a parent comment for threading.', 37 + "Information about the comment's position in the thread.", 43 38 }, 44 39 createdAt: { 45 40 type: 'string', ··· 50 45 }, 51 46 }, 52 47 }, 48 + replyRef: { 49 + type: 'object', 50 + required: ['root', 'parent'], 51 + properties: { 52 + root: { 53 + type: 'ref', 54 + ref: 'lex:com.atproto.repo.strongRef', 55 + description: 56 + 'Strong reference to the original track being commented on.', 57 + }, 58 + parent: { 59 + type: 'ref', 60 + ref: 'lex:com.atproto.repo.strongRef', 61 + description: 62 + "Strong reference to the immediate parent comment, or to the track itself if it's a top-level comment.", 63 + }, 64 + }, 65 + }, 53 66 }, 54 67 }, 55 68 AppMusicskyTempLike: { ··· 105 118 maxGraphemes: 1000, 106 119 description: 'Optional description of the playlist.', 107 120 }, 121 + coverArt: { 122 + type: 'blob', 123 + accept: ['image/png', 'image/jpeg', 'image/webp'], 124 + maxSize: 10000000, 125 + description: 'Cover art image for the playlist. Max 10 MB.', 126 + }, 108 127 tracks: { 109 128 type: 'array', 110 129 items: { 111 130 type: 'ref', 112 131 ref: 'lex:com.atproto.repo.strongRef', 113 132 }, 133 + minLength: 1, 114 134 maxLength: 500, 115 135 description: 116 - 'Ordered list of strong references to tracks. Max 500 tracks per playlist.', 136 + 'Ordered list of strong references to tracks. Min 1, max 500 tracks per playlist.', 117 137 }, 118 138 createdAt: { 119 139 type: 'string', ··· 121 141 description: 122 142 'Client-declared timestamp of when the playlist was created.', 123 143 }, 144 + updatedAt: { 145 + type: 'string', 146 + format: 'datetime', 147 + description: 148 + 'Client-declared timestamp of when the playlist was last updated.', 149 + }, 124 150 }, 125 151 }, 126 152 }, 127 153 }, 128 154 }, 129 - AppMusicskyTempTrack: { 155 + AppMusicskyTempRepost: { 130 156 lexicon: 1, 131 - id: 'app.musicsky.temp.track', 157 + id: 'app.musicsky.temp.repost', 158 + defs: { 159 + main: { 160 + type: 'record', 161 + description: 'Record representing an account reposting a track.', 162 + key: 'tid', 163 + record: { 164 + type: 'object', 165 + required: ['subject', 'createdAt'], 166 + properties: { 167 + subject: { 168 + type: 'ref', 169 + ref: 'lex:com.atproto.repo.strongRef', 170 + description: 'Strong reference to the track being reposted.', 171 + }, 172 + createdAt: { 173 + type: 'string', 174 + format: 'datetime', 175 + description: 176 + 'Client-declared timestamp of when the repost was created.', 177 + }, 178 + }, 179 + }, 180 + }, 181 + }, 182 + }, 183 + AppMusicskyTempSong: { 184 + lexicon: 1, 185 + id: 'app.musicsky.temp.song', 132 186 defs: { 133 187 main: { 134 188 type: 'record', ··· 136 190 key: 'tid', 137 191 record: { 138 192 type: 'object', 139 - required: ['title', 'audio', 'duration', 'createdAt'], 193 + required: [ 194 + 'title', 195 + 'audio', 196 + 'coverArt', 197 + 'slug', 198 + 'duration', 199 + 'createdAt', 200 + ], 140 201 properties: { 141 202 title: { 142 203 type: 'string', ··· 145 206 maxGraphemes: 100, 146 207 description: 'Title of the track.', 147 208 }, 209 + tags: { 210 + type: 'array', 211 + items: { 212 + type: 'string', 213 + maxLength: 128, 214 + maxGraphemes: 64, 215 + }, 216 + maxLength: 8, 217 + description: 'List of tags associated with the track.', 218 + }, 148 219 description: { 149 220 type: 'string', 150 221 maxLength: 5000, ··· 173 244 coverArt: { 174 245 type: 'blob', 175 246 accept: ['image/png', 'image/jpeg', 'image/webp'], 176 - maxSize: 1000000, 177 - description: 'Optional cover art image. Max 1 MB.', 247 + maxSize: 10000000, 248 + description: 'Cover art image. Max 10 MB.', 178 249 }, 179 250 duration: { 180 251 type: 'integer', 181 252 minimum: 1, 182 253 description: 'Duration of the track in seconds.', 183 254 }, 255 + slug: { 256 + type: 'string', 257 + maxLength: 128, 258 + maxGraphemes: 128, 259 + description: 'URL-friendly slug for the track.', 260 + }, 261 + labels: { 262 + type: 'union', 263 + refs: ['lex:com.atproto.label.defs#selfLabels'], 264 + description: 265 + 'Self-label values for this track. Use for content warnings (e.g. explicit lyrics).', 266 + }, 184 267 createdAt: { 185 268 type: 'string', 186 269 format: 'datetime', ··· 228 311 AppMusicskyTempComment: 'app.musicsky.temp.comment', 229 312 AppMusicskyTempLike: 'app.musicsky.temp.like', 230 313 AppMusicskyTempPlaylist: 'app.musicsky.temp.playlist', 231 - AppMusicskyTempTrack: 'app.musicsky.temp.track', 314 + AppMusicskyTempRepost: 'app.musicsky.temp.repost', 315 + AppMusicskyTempSong: 'app.musicsky.temp.song', 232 316 } as const
+17 -2
src/lib/lexicons/types/app/musicsky/temp/comment.ts
··· 19 19 $type: 'app.musicsky.temp.comment' 20 20 /** The comment text. */ 21 21 text: string 22 - subject: ComAtprotoRepoStrongRef.Main 23 - parent?: ComAtprotoRepoStrongRef.Main 22 + reply: ReplyRef 24 23 /** Client-declared timestamp of when the comment was created. */ 25 24 createdAt: string 26 25 [k: string]: unknown ··· 41 40 isMain as isRecord, 42 41 validateMain as validateRecord, 43 42 } 43 + 44 + export interface ReplyRef { 45 + $type?: 'app.musicsky.temp.comment#replyRef' 46 + root: ComAtprotoRepoStrongRef.Main 47 + parent: ComAtprotoRepoStrongRef.Main 48 + } 49 + 50 + const hashReplyRef = 'replyRef' 51 + 52 + export function isReplyRef<V>(v: V) { 53 + return is$typed(v, id, hashReplyRef) 54 + } 55 + 56 + export function validateReplyRef<V>(v: V) { 57 + return validate<ReplyRef & V>(v, id, hashReplyRef) 58 + }
+5 -1
src/lib/lexicons/types/app/musicsky/temp/playlist.ts
··· 21 21 name: string 22 22 /** Optional description of the playlist. */ 23 23 description?: string 24 - /** Ordered list of strong references to tracks. Max 500 tracks per playlist. */ 24 + /** Cover art image for the playlist. Max 10 MB. */ 25 + coverArt?: BlobRef 26 + /** Ordered list of strong references to tracks. Min 1, max 500 tracks per playlist. */ 25 27 tracks: ComAtprotoRepoStrongRef.Main[] 26 28 /** Client-declared timestamp of when the playlist was created. */ 27 29 createdAt: string 30 + /** Client-declared timestamp of when the playlist was last updated. */ 31 + updatedAt?: string 28 32 [k: string]: unknown 29 33 } 30 34
+40
src/lib/lexicons/types/app/musicsky/temp/repost.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { type ValidationResult, BlobRef } from '@atproto/lexicon' 5 + import { CID } from 'multiformats/cid' 6 + import { validate as _validate } from '../../../../lexicons' 7 + import { 8 + type $Typed, 9 + is$typed as _is$typed, 10 + type OmitKey, 11 + } from '../../../../util' 12 + import type * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef.js' 13 + 14 + const is$typed = _is$typed, 15 + validate = _validate 16 + const id = 'app.musicsky.temp.repost' 17 + 18 + export interface Main { 19 + $type: 'app.musicsky.temp.repost' 20 + subject: ComAtprotoRepoStrongRef.Main 21 + /** Client-declared timestamp of when the repost was created. */ 22 + createdAt: string 23 + [k: string]: unknown 24 + } 25 + 26 + const hashMain = 'main' 27 + 28 + export function isMain<V>(v: V) { 29 + return is$typed(v, id, hashMain) 30 + } 31 + 32 + export function validateMain<V>(v: V) { 33 + return validate<Main & V>(v, id, hashMain, true) 34 + } 35 + 36 + export { 37 + type Main as Record, 38 + isMain as isRecord, 39 + validateMain as validateRecord, 40 + }
+10 -4
src/lib/lexicons/types/app/musicsky/temp/track.ts src/lib/lexicons/types/app/musicsky/temp/song.ts
··· 9 9 is$typed as _is$typed, 10 10 type OmitKey, 11 11 } from '../../../../util' 12 + import type * as ComAtprotoLabelDefs from '../../../com/atproto/label/defs.js' 12 13 13 14 const is$typed = _is$typed, 14 15 validate = _validate 15 - const id = 'app.musicsky.temp.track' 16 + const id = 'app.musicsky.temp.song' 16 17 17 18 export interface Main { 18 - $type: 'app.musicsky.temp.track' 19 + $type: 'app.musicsky.temp.song' 19 20 /** Title of the track. */ 20 21 title: string 22 + /** List of tags associated with the track. */ 23 + tags?: string[] 21 24 /** Optional description or notes about the track. */ 22 25 description?: string 23 26 /** Genre label for the track. */ 24 27 genre?: string 25 28 /** The audio file blob. Max 50 MB. */ 26 29 audio: BlobRef 27 - /** Optional cover art image. Max 1 MB. */ 28 - coverArt?: BlobRef 30 + /** Cover art image. Max 10 MB. */ 31 + coverArt: BlobRef 29 32 /** Duration of the track in seconds. */ 30 33 duration: number 34 + /** URL-friendly slug for the track. */ 35 + slug: string 36 + labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string } 31 37 /** Client-declared timestamp of when the track was uploaded. */ 32 38 createdAt: string 33 39 [k: string]: unknown