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.

feat: implement comments with threading support

Add comment creation, display, and data model with full reply threading. Comments are stored with root (track) and parent (immediate parent) refs per the AT Protocol lexicon, exposing cid on CommentView to unblock reply creation. Lexicon text limit aligned to 300 chars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: mejsiejdev <mejsiejdev@gmail.com>

authored by

mejsiejdev
Claude Opus 4.6
and committed by tangled.org ed3e31e3 f26bcb95

+1005 -146
+44
apps/appview/src/db/migrations.ts
··· 86 86 await db.schema.dropTable("song").execute(); 87 87 }, 88 88 }, 89 + 90 + "002": { 91 + async up(db: Kysely<unknown>) { 92 + await db.schema 93 + .createTable("comment") 94 + .addColumn("uri", "text", (col) => col.primaryKey()) 95 + .addColumn("did", "text", (col) => col.notNull()) 96 + .addColumn("rkey", "text", (col) => col.notNull()) 97 + .addColumn("subject_uri", "text", (col) => col.notNull()) 98 + .addColumn("parent_uri", "text", (col) => col.notNull()) 99 + .addColumn("text", "text", (col) => col.notNull()) 100 + .addColumn("created_at", "text", (col) => col.notNull()) 101 + .execute(); 102 + 103 + await db.schema 104 + .createIndex("idx_comment_subject") 105 + .on("comment") 106 + .column("subject_uri") 107 + .execute(); 108 + 109 + await db.schema 110 + .createIndex("idx_comment_did_subject") 111 + .on("comment") 112 + .columns(["did", "subject_uri"]) 113 + .execute(); 114 + }, 115 + 116 + async down(db: Kysely<unknown>) { 117 + await db.schema.dropTable("comment").execute(); 118 + }, 119 + }, 120 + 121 + "003": { 122 + async up(db: Kysely<unknown>) { 123 + await db.schema 124 + .alterTable("comment") 125 + .addColumn("cid", "text", (col) => col.notNull().defaultTo("")) 126 + .execute(); 127 + }, 128 + 129 + async down(db: Kysely<unknown>) { 130 + await db.schema.alterTable("comment").dropColumn("cid").execute(); 131 + }, 132 + }, 89 133 }; 90 134 91 135 export function getMigrator() {
+12
apps/appview/src/db/schema.ts
··· 32 32 created_at: string; 33 33 } 34 34 35 + export interface CommentTable { 36 + uri: string; 37 + cid: string; 38 + did: string; 39 + rkey: string; 40 + subject_uri: string; 41 + parent_uri: string; 42 + text: string; 43 + created_at: string; 44 + } 45 + 35 46 export interface DatabaseSchema { 36 47 song: SongTable; 37 48 identity: IdentityTable; 38 49 like: LikeTable; 39 50 repost: RepostTable; 51 + comment: CommentTable; 40 52 }
+89
apps/appview/src/routes/comments.ts
··· 1 + import type { Request, Response } from "express"; 2 + import { getDb } from "../db/index.js"; 3 + import type { CommentsOutput, CommentView } from "../types/comments.js"; 4 + 5 + const DEFAULT_LIMIT = 20; 6 + const MAX_LIMIT = 50; 7 + 8 + export async function getCommentsHandler( 9 + req: Request, 10 + res: Response, 11 + ): Promise<void> { 12 + const uri = req.query["uri"] as string | undefined; 13 + 14 + if (!uri) { 15 + res.status(400).json({ error: "Missing required parameter: uri" }); 16 + return; 17 + } 18 + 19 + const limit = Math.min( 20 + parseInt(req.query["limit"] as string) || DEFAULT_LIMIT, 21 + MAX_LIMIT, 22 + ); 23 + const cursor = req.query["cursor"] as string | undefined; 24 + 25 + const db = getDb(); 26 + 27 + let query = db 28 + .selectFrom("comment as c") 29 + .innerJoin("identity as i", "i.did", "c.did") 30 + .select([ 31 + "c.uri", 32 + "c.cid", 33 + "c.did", 34 + "c.text", 35 + "c.created_at", 36 + "i.handle", 37 + "i.pds", 38 + ]) 39 + .where("c.subject_uri", "=", uri); 40 + 41 + if (cursor) { 42 + const [cursorTs, cursorUri] = cursor.split("::"); 43 + if (cursorTs && cursorUri) { 44 + query = query.where((eb) => 45 + eb.or([ 46 + eb("c.created_at", "<", cursorTs), 47 + eb.and([ 48 + eb("c.created_at", "=", cursorTs), 49 + eb("c.uri", "<", cursorUri), 50 + ]), 51 + ]), 52 + ); 53 + } 54 + } 55 + 56 + const [rows, countResult] = await Promise.all([ 57 + query 58 + .orderBy("c.created_at", "desc") 59 + .orderBy("c.uri", "desc") 60 + .limit(limit) 61 + .execute(), 62 + db 63 + .selectFrom("comment") 64 + .select((eb) => eb.fn.count<number>("uri").as("count")) 65 + .where("subject_uri", "=", uri) 66 + .executeTakeFirstOrThrow(), 67 + ]); 68 + 69 + const totalCount = Number(countResult.count); 70 + 71 + const comments: CommentView[] = rows.map((row) => ({ 72 + uri: row.uri, 73 + cid: row.cid, 74 + text: row.text, 75 + author: { 76 + did: row.did, 77 + handle: row.handle, 78 + pds: row.pds, 79 + }, 80 + createdAt: row.created_at, 81 + })); 82 + 83 + const lastRow = rows[rows.length - 1]; 84 + const nextCursor = lastRow 85 + ? `${lastRow.created_at}::${lastRow.uri}` 86 + : undefined; 87 + 88 + res.json({ cursor: nextCursor, totalCount, comments } satisfies CommentsOutput); 89 + }
+8
apps/appview/src/routes/index.ts
··· 1 1 import type { Express } from "express"; 2 2 import type { IdentityResolver } from "../identity/resolver.js"; 3 3 import { getFeedHandler } from "./feed.js"; 4 + import { getCommentsHandler } from "./comments.js"; 4 5 import { createBlobHandler } from "./blob.js"; 5 6 6 7 export function registerRoutes(app: Express, resolver: IdentityResolver): void { ··· 11 12 app.get("/xrpc/app.musicsky.temp.getFeed", (req, res) => { 12 13 getFeedHandler(req, res).catch((err) => { 13 14 console.error("getFeed error:", err); 15 + res.status(500).json({ error: "Internal server error" }); 16 + }); 17 + }); 18 + 19 + app.get("/xrpc/app.musicsky.temp.getComments", (req, res) => { 20 + getCommentsHandler(req, res).catch((err) => { 21 + console.error("getComments error:", err); 14 22 res.status(500).json({ error: "Internal server error" }); 15 23 }); 16 24 });
+122 -1
apps/appview/src/tap/consumer.test.ts
··· 2 2 import type { Kysely } from "kysely"; 3 3 import type { DatabaseSchema } from "../db/schema.js"; 4 4 import type { IdentityResolver } from "../identity/resolver.js"; 5 - import { handleSong, handleLike, handleRepost } from "./consumer.js"; 5 + import { 6 + handleSong, 7 + handleLike, 8 + handleRepost, 9 + handleComment, 10 + } from "./consumer.js"; 6 11 import { createTestDb } from "../tests/helpers/db.js"; 7 12 import { COLLECTIONS } from "common"; 8 13 ··· 209 214 expect(rows).toHaveLength(0); 210 215 }); 211 216 }); 217 + 218 + describe("handleComment", () => { 219 + let db: Kysely<DatabaseSchema>; 220 + 221 + beforeEach(async () => { 222 + db = await createTestDb(); 223 + }); 224 + 225 + it("inserts a comment on create", async () => { 226 + await handleComment( 227 + { 228 + action: "create", 229 + did: "did:plc:commenter", 230 + collection: COLLECTIONS.comment, 231 + rkey: "3jui7kd2zcszw", 232 + cid: "bafyreicomment", 233 + record: { 234 + text: "Great song!", 235 + reply: { 236 + root: { 237 + uri: "at://did:plc:abc/app.musicsky.temp.song/3jui7kd2zcszw", 238 + cid: "bafyreiabc", 239 + }, 240 + parent: { 241 + uri: "at://did:plc:abc/app.musicsky.temp.song/3jui7kd2zcszw", 242 + cid: "bafyreiabc", 243 + }, 244 + }, 245 + createdAt: "2026-03-22T00:00:00.000Z", 246 + }, 247 + } as never, 248 + db, 249 + ); 250 + 251 + const row = await db.selectFrom("comment").selectAll().executeTakeFirst(); 252 + expect(row).toBeDefined(); 253 + expect(row?.did).toBe("did:plc:commenter"); 254 + expect(row?.text).toBe("Great song!"); 255 + expect(row?.subject_uri).toBe( 256 + "at://did:plc:abc/app.musicsky.temp.song/3jui7kd2zcszw", 257 + ); 258 + expect(row?.parent_uri).toBe( 259 + "at://did:plc:abc/app.musicsky.temp.song/3jui7kd2zcszw", 260 + ); 261 + }); 262 + 263 + it("deletes a comment on delete", async () => { 264 + await handleComment( 265 + { 266 + action: "create", 267 + did: "did:plc:commenter", 268 + collection: COLLECTIONS.comment, 269 + rkey: "3jui7kd2zcszw", 270 + cid: "bafyreicomment", 271 + record: { 272 + text: "Great song!", 273 + reply: { 274 + root: { 275 + uri: "at://did:plc:abc/app.musicsky.temp.song/3jui7kd2zcszw", 276 + cid: "bafyreiabc", 277 + }, 278 + parent: { 279 + uri: "at://did:plc:abc/app.musicsky.temp.song/3jui7kd2zcszw", 280 + cid: "bafyreiabc", 281 + }, 282 + }, 283 + createdAt: "2026-03-22T00:00:00.000Z", 284 + }, 285 + } as never, 286 + db, 287 + ); 288 + 289 + await handleComment( 290 + { 291 + action: "delete", 292 + did: "did:plc:commenter", 293 + collection: COLLECTIONS.comment, 294 + rkey: "3jui7kd2zcszw", 295 + } as never, 296 + db, 297 + ); 298 + 299 + const rows = await db.selectFrom("comment").selectAll().execute(); 300 + expect(rows).toHaveLength(0); 301 + }); 302 + 303 + it("is idempotent — second insert with same URI is ignored", async () => { 304 + const evt = { 305 + action: "create", 306 + did: "did:plc:commenter", 307 + collection: COLLECTIONS.comment, 308 + rkey: "3jui7kd2zcszw", 309 + cid: "bafyreicomment", 310 + record: { 311 + text: "Great song!", 312 + reply: { 313 + root: { 314 + uri: "at://did:plc:abc/app.musicsky.temp.song/3jui7kd2zcszw", 315 + cid: "bafyreiabc", 316 + }, 317 + parent: { 318 + uri: "at://did:plc:abc/app.musicsky.temp.song/3jui7kd2zcszw", 319 + cid: "bafyreiabc", 320 + }, 321 + }, 322 + createdAt: "2026-03-22T00:00:00.000Z", 323 + }, 324 + } as never; 325 + 326 + await handleComment(evt, db); 327 + await handleComment(evt, db); 328 + 329 + const rows = await db.selectFrom("comment").selectAll().execute(); 330 + expect(rows).toHaveLength(1); 331 + }); 332 + });
+38
apps/appview/src/tap/consumer.ts
··· 9 9 const SONG = COLLECTIONS.song; 10 10 const LIKE = COLLECTIONS.like; 11 11 const REPOST = COLLECTIONS.repost; 12 + const COMMENT = COLLECTIONS.comment; 12 13 13 14 export async function handleSong( 14 15 evt: RecordEvent, ··· 114 115 .execute(); 115 116 } 116 117 118 + export async function handleComment( 119 + evt: RecordEvent, 120 + db: Kysely<DatabaseSchema>, 121 + ): Promise<void> { 122 + const uri = AtUri.make(evt.did, evt.collection, evt.rkey).toString(); 123 + 124 + if (evt.action === "delete") { 125 + await db.deleteFrom("comment").where("uri", "=", uri).execute(); 126 + return; 127 + } 128 + 129 + if (!evt.record || !evt.cid) return; 130 + 131 + const reply = evt.record["reply"] as 132 + | { root?: { uri: string }; parent?: { uri: string } } 133 + | undefined; 134 + const text = evt.record["text"] as string | undefined; 135 + if (!reply?.root?.uri || !reply?.parent?.uri || !text) return; 136 + 137 + await db 138 + .insertInto("comment") 139 + .values({ 140 + uri, 141 + cid: evt.cid, 142 + did: evt.did, 143 + rkey: evt.rkey, 144 + subject_uri: reply.root.uri, 145 + parent_uri: reply.parent.uri, 146 + text, 147 + created_at: getCreatedAtFromRkey(evt.rkey), 148 + }) 149 + .onConflict((oc) => oc.column("uri").doNothing()) 150 + .execute(); 151 + } 152 + 117 153 interface TapConsumerDeps { 118 154 db: Kysely<DatabaseSchema>; 119 155 resolver: IdentityResolver; ··· 145 181 await handleLike(evt, db); 146 182 } else if (evt.collection === REPOST) { 147 183 await handleRepost(evt, db); 184 + } else if (evt.collection === COMMENT) { 185 + await handleComment(evt, db); 148 186 } 149 187 } catch (err) { 150 188 console.error(`Error handling ${evt.collection} event:`, err);
+15
apps/appview/src/types/comments.ts
··· 1 + import type { AuthorView } from "./feed.js"; 2 + 3 + export interface CommentView { 4 + uri: string; 5 + cid: string; 6 + text: string; 7 + author: AuthorView; 8 + createdAt: string; 9 + } 10 + 11 + export interface CommentsOutput { 12 + cursor?: string; 13 + totalCount: number; 14 + comments: CommentView[]; 15 + }
+2 -1
apps/web/package.json
··· 14 14 "knip": "knip" 15 15 }, 16 16 "dependencies": { 17 - "common": "workspace:*", 18 17 "@atproto/api": "^0.19.0", 19 18 "@atproto/common-web": "^0.4.18", 20 19 "@atproto/lexicon": "^0.6.1", ··· 23 22 "better-sqlite3": "^12.6.2", 24 23 "class-variance-authority": "^0.7.1", 25 24 "clsx": "^2.1.1", 25 + "common": "workspace:*", 26 26 "date-fns": "^4.1.0", 27 27 "kysely": "^0.28.11", 28 28 "lucide-react": "^0.575.0", ··· 33 33 "react-dom": "^19.2.4", 34 34 "react-hook-form": "^7.71.2", 35 35 "sonner": "^2.0.7", 36 + "swr": "^2.4.1", 36 37 "tailwind-merge": "^3.5.0", 37 38 "zod": "^4.3.6", 38 39 "zustand": "^5.0.11"
+16 -7
apps/web/src/app/(main)/[handle]/[rkey]/song-view.tsx
··· 1 1 import { Song } from "@/components/song"; 2 + import { CommentSection } from "@/components/comment/comment-section"; 2 3 import { type TrackRecord } from "@/types/song"; 3 4 import { Agent } from "@atproto/api"; 4 5 import { notFound } from "next/navigation"; ··· 61 62 const isOwner = session?.did === song.uri.split("/")[2]; 62 63 63 64 return ( 64 - <Song 65 - {...song} 66 - isOwner={isOwner} 67 - loggedIn={session !== null} 68 - likeRkey={likedUris.get(song.uri) ?? null} 69 - repostRkey={repostedUris.get(song.uri) ?? null} 70 - /> 65 + <> 66 + <Song 67 + {...song} 68 + isOwner={isOwner} 69 + loggedIn={session !== null} 70 + likeRkey={likedUris.get(song.uri) ?? null} 71 + repostRkey={repostedUris.get(song.uri) ?? null} 72 + /> 73 + <CommentSection 74 + uri={song.uri} 75 + cid={song.cid} 76 + songTitle={song.title} 77 + isLoggedIn={session !== null} 78 + /> 79 + </> 71 80 ); 72 81 }
+41
apps/web/src/app/api/comments/route.ts
··· 1 + import { type NextRequest } from "next/server"; 2 + import { APPVIEW_URL } from "@/lib/api"; 3 + 4 + export async function GET(request: NextRequest) { 5 + const searchParams = request.nextUrl.searchParams; 6 + const uri = searchParams.get("uri"); 7 + 8 + if (!uri) { 9 + return Response.json( 10 + { error: "Missing required parameter: uri" }, 11 + { status: 400 }, 12 + ); 13 + } 14 + 15 + const params = new URLSearchParams({ uri }); 16 + const limit = searchParams.get("limit"); 17 + if (limit) params.set("limit", limit); 18 + const cursor = searchParams.get("cursor"); 19 + if (cursor) params.set("cursor", cursor); 20 + 21 + try { 22 + const res = await fetch( 23 + `${APPVIEW_URL}/xrpc/app.musicsky.temp.getComments?${params.toString()}`, 24 + ); 25 + 26 + if (!res.ok) { 27 + return Response.json( 28 + { error: "Failed to fetch comments" }, 29 + { status: res.status }, 30 + ); 31 + } 32 + 33 + const data = await res.json(); 34 + return Response.json(data); 35 + } catch { 36 + return Response.json( 37 + { error: "Failed to fetch comments" }, 38 + { status: 500 }, 39 + ); 40 + } 41 + }
+55
apps/web/src/components/comment/actions.ts
··· 1 + "use server"; 2 + 3 + import { Agent } from "@atproto/api"; 4 + import { updateTag } from "next/cache"; 5 + import { COLLECTIONS } from "@/lib/atproto"; 6 + import { type ActionResult, ok, fail } from "@/lib/action-result"; 7 + import { requireSession } from "@/lib/repo"; 8 + 9 + export async function createComment( 10 + _prevState: ActionResult | null, 11 + formData: FormData, 12 + ): Promise<ActionResult> { 13 + const text = (formData.get("text") as string).trim(); 14 + const trackUri = formData.get("trackUri") as string; 15 + const trackCid = formData.get("trackCid") as string; 16 + 17 + if (!text || text.length === 0) { 18 + return fail(new Error("Comment cannot be empty.")); 19 + } 20 + 21 + if (text.length > 300) { 22 + return fail(new Error("Comment must be 300 characters or fewer.")); 23 + } 24 + 25 + if (!trackUri || !trackCid) { 26 + return fail(new Error("Missing track reference.")); 27 + } 28 + 29 + const session = await requireSession(); 30 + const agent = new Agent(session); 31 + 32 + try { 33 + const trackRef = { uri: trackUri, cid: trackCid }; 34 + 35 + await agent.com.atproto.repo.createRecord({ 36 + repo: agent.assertDid, 37 + collection: COLLECTIONS.comment, 38 + record: { 39 + $type: COLLECTIONS.comment, 40 + text, 41 + reply: { 42 + root: trackRef, 43 + parent: trackRef, 44 + }, 45 + createdAt: new Date().toISOString(), 46 + }, 47 + }); 48 + 49 + updateTag(`comments-${trackUri}`); 50 + return ok(); 51 + } catch (error) { 52 + console.error("Failed to create comment:", error); 53 + return fail(error); 54 + } 55 + }
+96
apps/web/src/components/comment/comment-input.tsx
··· 1 + "use client"; 2 + 3 + import { startTransition, useActionState, useRef, useState } from "react"; 4 + import { Loader2Icon, MessageCirclePlusIcon } from "lucide-react"; 5 + import { Textarea } from "@/components/ui/textarea"; 6 + import { Button } from "@/components/ui/button"; 7 + import type { ActionResult } from "@/lib/action-result"; 8 + import { createComment } from "./actions"; 9 + 10 + const MAX_LENGTH = 300; 11 + 12 + export function CommentInput({ 13 + uri, 14 + cid, 15 + songTitle, 16 + onClose, 17 + onCommentPosted, 18 + }: { 19 + uri: string; 20 + cid: string; 21 + songTitle: string; 22 + onClose: () => void; 23 + onCommentPosted?: () => void; 24 + }) { 25 + const [text, setText] = useState(""); 26 + const textareaRef = useRef<HTMLTextAreaElement>(null); 27 + 28 + const [state, action, pending] = useActionState( 29 + async (prevState: ActionResult | null, formData: FormData) => { 30 + const result = await createComment(prevState, formData); 31 + if (result.success) { 32 + setText(""); 33 + onClose(); 34 + onCommentPosted?.(); 35 + } 36 + return result; 37 + }, 38 + null, 39 + ); 40 + 41 + function handleSubmit() { 42 + if (!text.trim() || text.length > MAX_LENGTH || pending) return; 43 + 44 + const formData = new FormData(); 45 + formData.set("text", text); 46 + formData.set("trackUri", uri); 47 + formData.set("trackCid", cid); 48 + 49 + startTransition(() => { 50 + action(formData); 51 + }); 52 + } 53 + 54 + function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) { 55 + if (event.key === "Enter" && !event.shiftKey) { 56 + event.preventDefault(); 57 + handleSubmit(); 58 + } 59 + } 60 + 61 + return ( 62 + <div className="flex flex-col gap-2"> 63 + {state && !state.success && ( 64 + <p className="text-sm text-destructive">{state.error}</p> 65 + )} 66 + <div className="flex flex-row items-end gap-2"> 67 + <Textarea 68 + ref={textareaRef} 69 + value={text} 70 + onChange={(event) => setText(event.target.value)} 71 + onKeyDown={handleKeyDown} 72 + maxLength={MAX_LENGTH} 73 + placeholder={`Comment on ${songTitle}...`} 74 + aria-label={`Comment on ${songTitle}`} 75 + disabled={pending} 76 + className="min-h-10" 77 + /> 78 + <Button 79 + size="icon" 80 + onClick={handleSubmit} 81 + disabled={pending || !text.trim()} 82 + aria-label="Submit comment" 83 + > 84 + {pending ? ( 85 + <Loader2Icon className="animate-spin" /> 86 + ) : ( 87 + <MessageCirclePlusIcon /> 88 + )} 89 + </Button> 90 + </div> 91 + <span className="text-xs text-muted-foreground"> 92 + {text.length}/{MAX_LENGTH} 93 + </span> 94 + </div> 95 + ); 96 + }
+160
apps/web/src/components/comment/comment-section.tsx
··· 1 + "use client"; 2 + 3 + import { useCallback, useState } from "react"; 4 + import useSWR from "swr"; 5 + import { MessageCircleIcon } from "lucide-react"; 6 + import { Comment } from "./comment"; 7 + import { CommentInput } from "./comment-input"; 8 + import { Button } from "@/components/ui/button"; 9 + import { Skeleton } from "@/components/ui/skeleton"; 10 + 11 + interface CommentAuthor { 12 + did: string; 13 + handle: string; 14 + pds: string; 15 + } 16 + 17 + interface CommentView { 18 + uri: string; 19 + text: string; 20 + author: CommentAuthor; 21 + createdAt: string; 22 + } 23 + 24 + interface CommentsResponse { 25 + comments: CommentView[]; 26 + cursor?: string; 27 + totalCount: number; 28 + } 29 + 30 + async function fetchComments(url: string): Promise<CommentsResponse> { 31 + const res = await fetch(url); 32 + if (!res.ok) throw new Error("Failed to fetch comments"); 33 + return (await res.json()) as CommentsResponse; 34 + } 35 + 36 + export function CommentSection({ 37 + uri, 38 + cid, 39 + songTitle, 40 + isLoggedIn, 41 + }: { 42 + uri: string; 43 + cid: string | undefined; 44 + songTitle: string; 45 + isLoggedIn: boolean; 46 + }) { 47 + const [extraComments, setExtraComments] = useState<CommentView[]>([]); 48 + const [cursor, setCursor] = useState<string | undefined>(); 49 + const [loadingMore, setLoadingMore] = useState(false); 50 + 51 + const { data, error, isLoading, mutate } = useSWR( 52 + `/api/comments?uri=${encodeURIComponent(uri)}&limit=20`, 53 + fetchComments, 54 + ); 55 + 56 + const handleCommentPosted = useCallback(() => { 57 + setExtraComments([]); 58 + setCursor(undefined); 59 + void mutate(); 60 + }, [mutate]); 61 + 62 + const initialCursor = data?.cursor; 63 + const activeCursor = cursor ?? initialCursor; 64 + 65 + async function handleLoadMore() { 66 + if (!activeCursor) return; 67 + setLoadingMore(true); 68 + try { 69 + const params = new URLSearchParams({ 70 + uri, 71 + limit: "20", 72 + cursor: activeCursor, 73 + }); 74 + const res = await fetchComments( 75 + `/api/comments?${params.toString()}`, 76 + ); 77 + setExtraComments((prev) => [...prev, ...res.comments]); 78 + setCursor(res.cursor); 79 + } finally { 80 + setLoadingMore(false); 81 + } 82 + } 83 + 84 + const allComments = [...(data?.comments ?? []), ...extraComments]; 85 + const totalCount = data?.totalCount ?? 0; 86 + const hasMore = Boolean(activeCursor) && allComments.length < totalCount; 87 + 88 + return ( 89 + <div className="flex flex-col gap-4"> 90 + <div className="flex flex-row items-center gap-2"> 91 + <MessageCircleIcon size={18} /> 92 + <h2 className="text-sm font-medium"> 93 + {isLoading 94 + ? "Comments" 95 + : `${totalCount} ${totalCount === 1 ? "comment" : "comments"}`} 96 + </h2> 97 + </div> 98 + 99 + {isLoggedIn && cid && ( 100 + <CommentInput 101 + uri={uri} 102 + cid={cid} 103 + songTitle={songTitle} 104 + onClose={() => {}} 105 + onCommentPosted={handleCommentPosted} 106 + /> 107 + )} 108 + 109 + {isLoading && ( 110 + <div className="flex flex-col gap-3"> 111 + {Array.from({ length: 3 }).map((_, index) => ( 112 + <div key={index} className="flex flex-row gap-3"> 113 + <Skeleton className="size-6 rounded-full shrink-0" /> 114 + <div className="flex flex-col gap-1 w-full"> 115 + <Skeleton className="h-4 w-32" /> 116 + <Skeleton className="h-4 w-full" /> 117 + </div> 118 + </div> 119 + ))} 120 + </div> 121 + )} 122 + 123 + {error && ( 124 + <p className="text-sm text-muted-foreground"> 125 + Failed to load comments. 126 + </p> 127 + )} 128 + 129 + {!isLoading && !error && allComments.length === 0 && ( 130 + <p className="text-sm text-muted-foreground"> 131 + No comments yet. Be the first to comment! 132 + </p> 133 + )} 134 + 135 + {allComments.length > 0 && ( 136 + <div className="flex flex-col gap-4"> 137 + {allComments.map((comment) => ( 138 + <Comment 139 + key={comment.uri} 140 + text={comment.text} 141 + author={comment.author} 142 + createdAt={comment.createdAt} 143 + /> 144 + ))} 145 + </div> 146 + )} 147 + 148 + {hasMore && ( 149 + <Button 150 + variant="outline" 151 + size="sm" 152 + onClick={() => void handleLoadMore()} 153 + disabled={loadingMore} 154 + > 155 + {loadingMore ? "Loading..." : "Load more comments"} 156 + </Button> 157 + )} 158 + </div> 159 + ); 160 + }
+91
apps/web/src/components/comment/comment.tsx
··· 1 + "use client"; 2 + 3 + import Link from "next/link"; 4 + import useSWR from "swr"; 5 + import { formatDistanceToNow } from "date-fns"; 6 + import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 7 + import { 8 + Tooltip, 9 + TooltipContent, 10 + TooltipTrigger, 11 + } from "@/components/ui/tooltip"; 12 + import { format } from "date-fns"; 13 + 14 + interface CommentAuthor { 15 + did: string; 16 + handle: string; 17 + pds: string; 18 + } 19 + 20 + interface ProfileData { 21 + avatar?: string; 22 + displayName?: string; 23 + handle: string; 24 + } 25 + 26 + async function fetchProfile(did: string): Promise<ProfileData | null> { 27 + try { 28 + const res = await fetch( 29 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`, 30 + ); 31 + if (!res.ok) return null; 32 + return (await res.json()) as ProfileData; 33 + } catch { 34 + return null; 35 + } 36 + } 37 + 38 + export function Comment({ 39 + text, 40 + author, 41 + createdAt, 42 + }: { 43 + text: string; 44 + author: CommentAuthor; 45 + createdAt: string; 46 + }) { 47 + const { data: profile } = useSWR(`profile-${author.did}`, () => 48 + fetchProfile(author.did), 49 + ); 50 + 51 + const createdAtDate = new Date(createdAt); 52 + 53 + return ( 54 + <div className="flex flex-row gap-3"> 55 + <Link href={`/${author.handle}`} className="shrink-0"> 56 + <Avatar size="lg"> 57 + {profile?.avatar && ( 58 + <AvatarImage src={profile.avatar} alt={author.handle} /> 59 + )} 60 + <AvatarFallback>{author.handle.slice(0, 2)}</AvatarFallback> 61 + </Avatar> 62 + </Link> 63 + <div className="flex flex-col gap-0.5 min-w-0"> 64 + <div className="flex flex-row items-center gap-2"> 65 + <Link 66 + href={`/${author.handle}`} 67 + className="text-sm font-medium hover:underline truncate" 68 + > 69 + {profile?.displayName ?? author.handle} 70 + </Link> 71 + <p className="text-sm text-muted-foreground">@{author.handle}</p> 72 + <p className="text-sm text-muted-foreground">·</p> 73 + <Tooltip> 74 + <TooltipTrigger asChild> 75 + <time 76 + dateTime={createdAt} 77 + className="text-xs text-muted-foreground shrink-0" 78 + > 79 + {formatDistanceToNow(createdAtDate, { 80 + addSuffix: true, 81 + })} 82 + </time> 83 + </TooltipTrigger> 84 + <TooltipContent>{format(createdAtDate, "PPP p")}</TooltipContent> 85 + </Tooltip> 86 + </div> 87 + <p className="text-sm break-words">{text}</p> 88 + </div> 89 + </div> 90 + ); 91 + }
-129
apps/web/src/components/song/comment-dialog.tsx
··· 1 - "use client"; 2 - 3 - import { Button } from "../ui/button"; 4 - import { 5 - Dialog, 6 - DialogContent, 7 - DialogHeader, 8 - DialogTitle, 9 - DialogTrigger, 10 - } from "../ui/dialog"; 11 - import { Input } from "../ui/input"; 12 - import { Field, FieldError } from "../ui/field"; 13 - import { Loader2Icon, MessageCirclePlusIcon } from "lucide-react"; 14 - import { uploadSong } from "@/components/song/upload-action"; 15 - import type { ActionResult } from "@/lib/action-result"; 16 - import { startTransition, useActionState, useState } from "react"; 17 - import { useForm } from "react-hook-form"; 18 - import { zodResolver } from "@hookform/resolvers/zod"; 19 - import { useRouter } from "next/navigation"; 20 - import { z } from "zod"; 21 - 22 - const commentSchema = z.object({ 23 - comment: z.string().min(1, "Comment cannot be empty"), 24 - }); 25 - 26 - type CommentFormData = z.infer<typeof commentSchema>; 27 - 28 - export function CommentDialog({ 29 - children, 30 - songTitle, 31 - isLoggedIn, 32 - }: { 33 - children: React.ReactNode; 34 - songTitle: string; 35 - isLoggedIn: boolean; 36 - }) { 37 - const [open, setOpen] = useState(false); 38 - const router = useRouter(); 39 - 40 - const { 41 - register, 42 - handleSubmit, 43 - reset, 44 - formState: { errors }, 45 - } = useForm<CommentFormData>({ 46 - resolver: zodResolver(commentSchema), 47 - }); 48 - 49 - const [state, action, pending] = useActionState( 50 - async ( 51 - prevState: ActionResult<{ handle: string; rkey: string }> | null, 52 - formData: FormData, 53 - ) => { 54 - const result = await uploadSong(prevState, formData); 55 - if (result.success) { 56 - setOpen(false); 57 - reset(); 58 - router.push(`/${result.data.handle}/${result.data.rkey}`); 59 - } 60 - return result; 61 - }, 62 - null, 63 - ); 64 - 65 - async function onSubmit(data: CommentFormData) { 66 - const formData = new FormData(); 67 - formData.set("comment", data.comment); 68 - startTransition(() => { 69 - action(formData); 70 - }); 71 - } 72 - 73 - return ( 74 - <Dialog 75 - open={open} 76 - onOpenChange={(value) => { 77 - setOpen(value); 78 - if (!value) { 79 - reset(); 80 - } 81 - }} 82 - > 83 - <DialogTrigger asChild>{children}</DialogTrigger> 84 - <DialogContent> 85 - <DialogHeader> 86 - <DialogTitle>Leave a comment on {songTitle}</DialogTitle> 87 - </DialogHeader> 88 - {isLoggedIn ? ( 89 - <> 90 - {state && !state.success && ( 91 - <p className="text-sm text-destructive">{state.error}</p> 92 - )} 93 - <form 94 - onSubmit={(event) => void handleSubmit(onSubmit)(event)} 95 - className="flex flex-row items-center gap-4" 96 - > 97 - <Field data-invalid={!!errors.comment}> 98 - <Input 99 - id="comment-title" 100 - type="text" 101 - placeholder="Enter your comment..." 102 - {...register("comment")} 103 - /> 104 - <FieldError>{errors.comment?.message}</FieldError> 105 - </Field> 106 - <Button type="submit" disabled={pending}> 107 - {pending ? ( 108 - <> 109 - <Loader2Icon className="animate-spin" /> 110 - Commenting... 111 - </> 112 - ) : ( 113 - <MessageCirclePlusIcon /> 114 - )} 115 - </Button> 116 - </form> 117 - </> 118 - ) : ( 119 - <> 120 - <p className="text-sm"> 121 - You need to be logged in to leave a comment. 122 - </p> 123 - <Button onClick={() => router.push("/auth/login")}>Login</Button> 124 - </> 125 - )} 126 - </DialogContent> 127 - </Dialog> 128 - ); 129 - }
+50 -3
apps/web/src/components/song/song.tsx
··· 12 12 TrashIcon, 13 13 MessageCircleIcon, 14 14 } from "lucide-react"; 15 + import { useEffect, useRef, useState } from "react"; 15 16 import { usePlayerStore } from "@/stores/player-store"; 16 17 import { useInteraction } from "@/hooks/use-interaction"; 17 18 import { cn } from "@/lib/utils"; ··· 27 28 import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; 28 29 import { formatDistanceToNow, format } from "date-fns"; 29 30 import { usePlaylistQueue } from "@/components/playlist/playlist-queue-context"; 30 - import { CommentDialog } from "./comment-dialog"; 31 + import { CommentInput } from "@/components/comment/comment-input"; 31 32 32 33 const AddToPlaylistDialog = dynamic( 33 34 () => ··· 95 96 96 97 const { optimisticLiked, optimisticReposted, handleLike, handleRepost } = 97 98 useInteraction({ uri, cid, author, likeRkey, repostRkey }); 99 + 100 + const [commentOpen, setCommentOpen] = useState(false); 101 + const commentAreaRef = useRef<HTMLDivElement>(null); 102 + const commentButtonRef = useRef<HTMLButtonElement>(null); 103 + 104 + useEffect(() => { 105 + if (!commentOpen) return; 106 + function handler(event: MouseEvent) { 107 + const target = event.target as Node; 108 + if ( 109 + commentAreaRef.current && 110 + !commentAreaRef.current.contains(target) && 111 + commentButtonRef.current && 112 + !commentButtonRef.current.contains(target) 113 + ) { 114 + setCommentOpen(false); 115 + } 116 + } 117 + document.addEventListener("mousedown", handler); 118 + return () => document.removeEventListener("mousedown", handler); 119 + }, [commentOpen]); 98 120 99 121 const queueSongs = usePlaylistQueue(); 100 122 const createdAtDate = new Date(createdAt); ··· 217 239 fill={optimisticLiked ? "currentColor" : "none"} 218 240 /> 219 241 </button> 220 - <CommentDialog songTitle={title} isLoggedIn={loggedIn}> 242 + {loggedIn ? ( 221 243 <button 244 + ref={commentButtonRef} 245 + onClick={() => setCommentOpen((prev) => !prev)} 222 246 aria-label="Comment" 247 + disabled={!cid} 223 248 className="flex flex-row items-center gap-2 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" 224 249 > 225 250 <MessageCircleIcon size={18} /> 226 251 </button> 227 - </CommentDialog> 252 + ) : ( 253 + <Tooltip> 254 + <TooltipTrigger asChild> 255 + <button 256 + aria-label="Comment" 257 + className="flex flex-row items-center gap-2 cursor-pointer opacity-50" 258 + > 259 + <MessageCircleIcon size={18} /> 260 + </button> 261 + </TooltipTrigger> 262 + <TooltipContent>Log in to comment</TooltipContent> 263 + </Tooltip> 264 + )} 228 265 </div> 229 266 <div className="flex flex-row items-center gap-4"> 230 267 <SharePopover shareUrl={shareUrl} /> ··· 256 293 )} 257 294 </div> 258 295 </div> 296 + {commentOpen && cid && ( 297 + <div ref={commentAreaRef}> 298 + <CommentInput 299 + uri={uri} 300 + cid={cid} 301 + songTitle={title} 302 + onClose={() => setCommentOpen(false)} 303 + /> 304 + </div> 305 + )} 259 306 </div> 260 307 ); 261 308 }
+1
packages/common/src/atproto.ts
··· 32 32 playlist: "app.musicsky.temp.playlist", 33 33 like: "app.musicsky.temp.like", 34 34 repost: "app.musicsky.temp.repost", 35 + comment: "app.musicsky.temp.comment", 35 36 } as const;
+2 -2
packages/lexicons/defs/app/musicsky/temp/comment.json
··· 17 17 "text": { 18 18 "type": "string", 19 19 "minLength": 1, 20 - "maxLength": 5000, 21 - "maxGraphemes": 1000, 20 + "maxLength": 300, 21 + "maxGraphemes": 300, 22 22 "description": "The comment text." 23 23 }, 24 24 "reply": {
+67
packages/lexicons/defs/app/musicsky/temp/getComments.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.musicsky.temp.getComments", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get comments for a track.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["uri"], 11 + "properties": { 12 + "uri": { 13 + "type": "string", 14 + "description": "AT URI of the track to get comments for." 15 + }, 16 + "limit": { 17 + "type": "integer", 18 + "minimum": 1, 19 + "maximum": 50, 20 + "default": 20, 21 + "description": "Maximum number of results." 22 + }, 23 + "cursor": { 24 + "type": "string", 25 + "description": "Pagination cursor." 26 + } 27 + } 28 + }, 29 + "output": { 30 + "encoding": "application/json", 31 + "schema": { 32 + "type": "object", 33 + "required": ["comments", "totalCount"], 34 + "properties": { 35 + "cursor": { 36 + "type": "string" 37 + }, 38 + "totalCount": { 39 + "type": "integer", 40 + "description": "Total number of comments on this track." 41 + }, 42 + "comments": { 43 + "type": "array", 44 + "items": { 45 + "type": "ref", 46 + "ref": "#commentView" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }, 53 + "commentView": { 54 + "type": "object", 55 + "required": ["uri", "text", "author", "createdAt"], 56 + "properties": { 57 + "uri": { "type": "string" }, 58 + "text": { "type": "string" }, 59 + "author": { 60 + "type": "ref", 61 + "ref": "app.musicsky.temp.getFeed#authorView" 62 + }, 63 + "createdAt": { "type": "string" } 64 + } 65 + } 66 + } 67 + }
+96 -3
pnpm-lock.yaml
··· 152 152 sonner: 153 153 specifier: ^2.0.7 154 154 version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 155 + swr: 156 + specifier: ^2.4.1 157 + version: 2.4.1(react@19.2.4) 155 158 tailwind-merge: 156 159 specifier: ^3.5.0 157 160 version: 3.5.0 ··· 1234 1237 } 1235 1238 cpu: [arm64] 1236 1239 os: [linux] 1240 + libc: [glibc] 1237 1241 1238 1242 "@img/sharp-libvips-linux-arm@1.2.4": 1239 1243 resolution: ··· 1242 1246 } 1243 1247 cpu: [arm] 1244 1248 os: [linux] 1249 + libc: [glibc] 1245 1250 1246 1251 "@img/sharp-libvips-linux-ppc64@1.2.4": 1247 1252 resolution: ··· 1250 1255 } 1251 1256 cpu: [ppc64] 1252 1257 os: [linux] 1258 + libc: [glibc] 1253 1259 1254 1260 "@img/sharp-libvips-linux-riscv64@1.2.4": 1255 1261 resolution: ··· 1258 1264 } 1259 1265 cpu: [riscv64] 1260 1266 os: [linux] 1267 + libc: [glibc] 1261 1268 1262 1269 "@img/sharp-libvips-linux-s390x@1.2.4": 1263 1270 resolution: ··· 1266 1273 } 1267 1274 cpu: [s390x] 1268 1275 os: [linux] 1276 + libc: [glibc] 1269 1277 1270 1278 "@img/sharp-libvips-linux-x64@1.2.4": 1271 1279 resolution: ··· 1274 1282 } 1275 1283 cpu: [x64] 1276 1284 os: [linux] 1285 + libc: [glibc] 1277 1286 1278 1287 "@img/sharp-libvips-linuxmusl-arm64@1.2.4": 1279 1288 resolution: ··· 1282 1291 } 1283 1292 cpu: [arm64] 1284 1293 os: [linux] 1294 + libc: [musl] 1285 1295 1286 1296 "@img/sharp-libvips-linuxmusl-x64@1.2.4": 1287 1297 resolution: ··· 1290 1300 } 1291 1301 cpu: [x64] 1292 1302 os: [linux] 1303 + libc: [musl] 1293 1304 1294 1305 "@img/sharp-linux-arm64@0.34.5": 1295 1306 resolution: ··· 1299 1310 engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } 1300 1311 cpu: [arm64] 1301 1312 os: [linux] 1313 + libc: [glibc] 1302 1314 1303 1315 "@img/sharp-linux-arm@0.34.5": 1304 1316 resolution: ··· 1308 1320 engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } 1309 1321 cpu: [arm] 1310 1322 os: [linux] 1323 + libc: [glibc] 1311 1324 1312 1325 "@img/sharp-linux-ppc64@0.34.5": 1313 1326 resolution: ··· 1317 1330 engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } 1318 1331 cpu: [ppc64] 1319 1332 os: [linux] 1333 + libc: [glibc] 1320 1334 1321 1335 "@img/sharp-linux-riscv64@0.34.5": 1322 1336 resolution: ··· 1326 1340 engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } 1327 1341 cpu: [riscv64] 1328 1342 os: [linux] 1343 + libc: [glibc] 1329 1344 1330 1345 "@img/sharp-linux-s390x@0.34.5": 1331 1346 resolution: ··· 1335 1350 engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } 1336 1351 cpu: [s390x] 1337 1352 os: [linux] 1353 + libc: [glibc] 1338 1354 1339 1355 "@img/sharp-linux-x64@0.34.5": 1340 1356 resolution: ··· 1344 1360 engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } 1345 1361 cpu: [x64] 1346 1362 os: [linux] 1363 + libc: [glibc] 1347 1364 1348 1365 "@img/sharp-linuxmusl-arm64@0.34.5": 1349 1366 resolution: ··· 1353 1370 engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } 1354 1371 cpu: [arm64] 1355 1372 os: [linux] 1373 + libc: [musl] 1356 1374 1357 1375 "@img/sharp-linuxmusl-x64@0.34.5": 1358 1376 resolution: ··· 1362 1380 engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } 1363 1381 cpu: [x64] 1364 1382 os: [linux] 1383 + libc: [musl] 1365 1384 1366 1385 "@img/sharp-wasm32@0.34.5": 1367 1386 resolution: ··· 1563 1582 engines: { node: ">= 10" } 1564 1583 cpu: [arm64] 1565 1584 os: [linux] 1585 + libc: [glibc] 1566 1586 1567 1587 "@next/swc-linux-arm64-musl@16.2.0-canary.103": 1568 1588 resolution: ··· 1572 1592 engines: { node: ">= 10" } 1573 1593 cpu: [arm64] 1574 1594 os: [linux] 1595 + libc: [musl] 1575 1596 1576 1597 "@next/swc-linux-x64-gnu@16.2.0-canary.103": 1577 1598 resolution: ··· 1581 1602 engines: { node: ">= 10" } 1582 1603 cpu: [x64] 1583 1604 os: [linux] 1605 + libc: [glibc] 1584 1606 1585 1607 "@next/swc-linux-x64-musl@16.2.0-canary.103": 1586 1608 resolution: ··· 1590 1612 engines: { node: ">= 10" } 1591 1613 cpu: [x64] 1592 1614 os: [linux] 1615 + libc: [musl] 1593 1616 1594 1617 "@next/swc-win32-arm64-msvc@16.2.0-canary.103": 1595 1618 resolution: ··· 1745 1768 } 1746 1769 cpu: [arm64] 1747 1770 os: [linux] 1771 + libc: [glibc] 1748 1772 1749 1773 "@oxc-resolver/binding-linux-arm64-musl@11.19.1": 1750 1774 resolution: ··· 1753 1777 } 1754 1778 cpu: [arm64] 1755 1779 os: [linux] 1780 + libc: [musl] 1756 1781 1757 1782 "@oxc-resolver/binding-linux-ppc64-gnu@11.19.1": 1758 1783 resolution: ··· 1761 1786 } 1762 1787 cpu: [ppc64] 1763 1788 os: [linux] 1789 + libc: [glibc] 1764 1790 1765 1791 "@oxc-resolver/binding-linux-riscv64-gnu@11.19.1": 1766 1792 resolution: ··· 1769 1795 } 1770 1796 cpu: [riscv64] 1771 1797 os: [linux] 1798 + libc: [glibc] 1772 1799 1773 1800 "@oxc-resolver/binding-linux-riscv64-musl@11.19.1": 1774 1801 resolution: ··· 1777 1804 } 1778 1805 cpu: [riscv64] 1779 1806 os: [linux] 1807 + libc: [musl] 1780 1808 1781 1809 "@oxc-resolver/binding-linux-s390x-gnu@11.19.1": 1782 1810 resolution: ··· 1785 1813 } 1786 1814 cpu: [s390x] 1787 1815 os: [linux] 1816 + libc: [glibc] 1788 1817 1789 1818 "@oxc-resolver/binding-linux-x64-gnu@11.19.1": 1790 1819 resolution: ··· 1793 1822 } 1794 1823 cpu: [x64] 1795 1824 os: [linux] 1825 + libc: [glibc] 1796 1826 1797 1827 "@oxc-resolver/binding-linux-x64-musl@11.19.1": 1798 1828 resolution: ··· 1801 1831 } 1802 1832 cpu: [x64] 1803 1833 os: [linux] 1834 + libc: [musl] 1804 1835 1805 1836 "@oxc-resolver/binding-openharmony-arm64@11.19.1": 1806 1837 resolution: ··· 2771 2802 engines: { node: ^20.19.0 || >=22.12.0 } 2772 2803 cpu: [arm64] 2773 2804 os: [linux] 2805 + libc: [glibc] 2774 2806 2775 2807 "@rolldown/binding-linux-arm64-musl@1.0.0-rc.10": 2776 2808 resolution: ··· 2780 2812 engines: { node: ^20.19.0 || >=22.12.0 } 2781 2813 cpu: [arm64] 2782 2814 os: [linux] 2815 + libc: [musl] 2783 2816 2784 2817 "@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10": 2785 2818 resolution: ··· 2789 2822 engines: { node: ^20.19.0 || >=22.12.0 } 2790 2823 cpu: [ppc64] 2791 2824 os: [linux] 2825 + libc: [glibc] 2792 2826 2793 2827 "@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10": 2794 2828 resolution: ··· 2798 2832 engines: { node: ^20.19.0 || >=22.12.0 } 2799 2833 cpu: [s390x] 2800 2834 os: [linux] 2835 + libc: [glibc] 2801 2836 2802 2837 "@rolldown/binding-linux-x64-gnu@1.0.0-rc.10": 2803 2838 resolution: ··· 2807 2842 engines: { node: ^20.19.0 || >=22.12.0 } 2808 2843 cpu: [x64] 2809 2844 os: [linux] 2845 + libc: [glibc] 2810 2846 2811 2847 "@rolldown/binding-linux-x64-musl@1.0.0-rc.10": 2812 2848 resolution: ··· 2816 2852 engines: { node: ^20.19.0 || >=22.12.0 } 2817 2853 cpu: [x64] 2818 2854 os: [linux] 2855 + libc: [musl] 2819 2856 2820 2857 "@rolldown/binding-openharmony-arm64@1.0.0-rc.10": 2821 2858 resolution: ··· 2913 2950 } 2914 2951 cpu: [arm] 2915 2952 os: [linux] 2953 + libc: [glibc] 2916 2954 2917 2955 "@rollup/rollup-linux-arm-musleabihf@4.59.1": 2918 2956 resolution: ··· 2921 2959 } 2922 2960 cpu: [arm] 2923 2961 os: [linux] 2962 + libc: [musl] 2924 2963 2925 2964 "@rollup/rollup-linux-arm64-gnu@4.59.1": 2926 2965 resolution: ··· 2929 2968 } 2930 2969 cpu: [arm64] 2931 2970 os: [linux] 2971 + libc: [glibc] 2932 2972 2933 2973 "@rollup/rollup-linux-arm64-musl@4.59.1": 2934 2974 resolution: ··· 2937 2977 } 2938 2978 cpu: [arm64] 2939 2979 os: [linux] 2980 + libc: [musl] 2940 2981 2941 2982 "@rollup/rollup-linux-loong64-gnu@4.59.1": 2942 2983 resolution: ··· 2945 2986 } 2946 2987 cpu: [loong64] 2947 2988 os: [linux] 2989 + libc: [glibc] 2948 2990 2949 2991 "@rollup/rollup-linux-loong64-musl@4.59.1": 2950 2992 resolution: ··· 2953 2995 } 2954 2996 cpu: [loong64] 2955 2997 os: [linux] 2998 + libc: [musl] 2956 2999 2957 3000 "@rollup/rollup-linux-ppc64-gnu@4.59.1": 2958 3001 resolution: ··· 2961 3004 } 2962 3005 cpu: [ppc64] 2963 3006 os: [linux] 3007 + libc: [glibc] 2964 3008 2965 3009 "@rollup/rollup-linux-ppc64-musl@4.59.1": 2966 3010 resolution: ··· 2969 3013 } 2970 3014 cpu: [ppc64] 2971 3015 os: [linux] 3016 + libc: [musl] 2972 3017 2973 3018 "@rollup/rollup-linux-riscv64-gnu@4.59.1": 2974 3019 resolution: ··· 2977 3022 } 2978 3023 cpu: [riscv64] 2979 3024 os: [linux] 3025 + libc: [glibc] 2980 3026 2981 3027 "@rollup/rollup-linux-riscv64-musl@4.59.1": 2982 3028 resolution: ··· 2985 3031 } 2986 3032 cpu: [riscv64] 2987 3033 os: [linux] 3034 + libc: [musl] 2988 3035 2989 3036 "@rollup/rollup-linux-s390x-gnu@4.59.1": 2990 3037 resolution: ··· 2993 3040 } 2994 3041 cpu: [s390x] 2995 3042 os: [linux] 3043 + libc: [glibc] 2996 3044 2997 3045 "@rollup/rollup-linux-x64-gnu@4.59.1": 2998 3046 resolution: ··· 3001 3049 } 3002 3050 cpu: [x64] 3003 3051 os: [linux] 3052 + libc: [glibc] 3004 3053 3005 3054 "@rollup/rollup-linux-x64-musl@4.59.1": 3006 3055 resolution: ··· 3009 3058 } 3010 3059 cpu: [x64] 3011 3060 os: [linux] 3061 + libc: [musl] 3012 3062 3013 3063 "@rollup/rollup-openbsd-x64@4.59.1": 3014 3064 resolution: ··· 3154 3204 engines: { node: ">= 20" } 3155 3205 cpu: [arm64] 3156 3206 os: [linux] 3207 + libc: [glibc] 3157 3208 3158 3209 "@tailwindcss/oxide-linux-arm64-musl@4.2.1": 3159 3210 resolution: ··· 3163 3214 engines: { node: ">= 20" } 3164 3215 cpu: [arm64] 3165 3216 os: [linux] 3217 + libc: [musl] 3166 3218 3167 3219 "@tailwindcss/oxide-linux-x64-gnu@4.2.1": 3168 3220 resolution: ··· 3172 3224 engines: { node: ">= 20" } 3173 3225 cpu: [x64] 3174 3226 os: [linux] 3227 + libc: [glibc] 3175 3228 3176 3229 "@tailwindcss/oxide-linux-x64-musl@4.2.1": 3177 3230 resolution: ··· 3181 3234 engines: { node: ">= 20" } 3182 3235 cpu: [x64] 3183 3236 os: [linux] 3237 + libc: [musl] 3184 3238 3185 3239 "@tailwindcss/oxide-wasm32-wasi@4.2.1": 3186 3240 resolution: ··· 3574 3628 } 3575 3629 cpu: [arm64] 3576 3630 os: [linux] 3631 + libc: [glibc] 3577 3632 3578 3633 "@unrs/resolver-binding-linux-arm64-musl@1.11.1": 3579 3634 resolution: ··· 3582 3637 } 3583 3638 cpu: [arm64] 3584 3639 os: [linux] 3640 + libc: [musl] 3585 3641 3586 3642 "@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": 3587 3643 resolution: ··· 3590 3646 } 3591 3647 cpu: [ppc64] 3592 3648 os: [linux] 3649 + libc: [glibc] 3593 3650 3594 3651 "@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": 3595 3652 resolution: ··· 3598 3655 } 3599 3656 cpu: [riscv64] 3600 3657 os: [linux] 3658 + libc: [glibc] 3601 3659 3602 3660 "@unrs/resolver-binding-linux-riscv64-musl@1.11.1": 3603 3661 resolution: ··· 3606 3664 } 3607 3665 cpu: [riscv64] 3608 3666 os: [linux] 3667 + libc: [musl] 3609 3668 3610 3669 "@unrs/resolver-binding-linux-s390x-gnu@1.11.1": 3611 3670 resolution: ··· 3614 3673 } 3615 3674 cpu: [s390x] 3616 3675 os: [linux] 3676 + libc: [glibc] 3617 3677 3618 3678 "@unrs/resolver-binding-linux-x64-gnu@1.11.1": 3619 3679 resolution: ··· 3622 3682 } 3623 3683 cpu: [x64] 3624 3684 os: [linux] 3685 + libc: [glibc] 3625 3686 3626 3687 "@unrs/resolver-binding-linux-x64-musl@1.11.1": 3627 3688 resolution: ··· 3630 3691 } 3631 3692 cpu: [x64] 3632 3693 os: [linux] 3694 + libc: [musl] 3633 3695 3634 3696 "@unrs/resolver-binding-wasm32-wasi@1.11.1": 3635 3697 resolution: ··· 4530 4592 integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==, 4531 4593 } 4532 4594 engines: { node: ">= 0.8" } 4595 + 4596 + dequal@2.0.3: 4597 + resolution: 4598 + { 4599 + integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==, 4600 + } 4601 + engines: { node: ">=6" } 4533 4602 4534 4603 detect-libc@2.1.2: 4535 4604 resolution: ··· 6205 6274 engines: { node: ">= 12.0.0" } 6206 6275 cpu: [arm64] 6207 6276 os: [linux] 6277 + libc: [glibc] 6208 6278 6209 6279 lightningcss-linux-arm64-gnu@1.32.0: 6210 6280 resolution: ··· 6214 6284 engines: { node: ">= 12.0.0" } 6215 6285 cpu: [arm64] 6216 6286 os: [linux] 6287 + libc: [glibc] 6217 6288 6218 6289 lightningcss-linux-arm64-musl@1.31.1: 6219 6290 resolution: ··· 6223 6294 engines: { node: ">= 12.0.0" } 6224 6295 cpu: [arm64] 6225 6296 os: [linux] 6297 + libc: [musl] 6226 6298 6227 6299 lightningcss-linux-arm64-musl@1.32.0: 6228 6300 resolution: ··· 6232 6304 engines: { node: ">= 12.0.0" } 6233 6305 cpu: [arm64] 6234 6306 os: [linux] 6307 + libc: [musl] 6235 6308 6236 6309 lightningcss-linux-x64-gnu@1.31.1: 6237 6310 resolution: ··· 6241 6314 engines: { node: ">= 12.0.0" } 6242 6315 cpu: [x64] 6243 6316 os: [linux] 6317 + libc: [glibc] 6244 6318 6245 6319 lightningcss-linux-x64-gnu@1.32.0: 6246 6320 resolution: ··· 6250 6324 engines: { node: ">= 12.0.0" } 6251 6325 cpu: [x64] 6252 6326 os: [linux] 6327 + libc: [glibc] 6253 6328 6254 6329 lightningcss-linux-x64-musl@1.31.1: 6255 6330 resolution: ··· 6259 6334 engines: { node: ">= 12.0.0" } 6260 6335 cpu: [x64] 6261 6336 os: [linux] 6337 + libc: [musl] 6262 6338 6263 6339 lightningcss-linux-x64-musl@1.32.0: 6264 6340 resolution: ··· 6268 6344 engines: { node: ">= 12.0.0" } 6269 6345 cpu: [x64] 6270 6346 os: [linux] 6347 + libc: [musl] 6271 6348 6272 6349 lightningcss-win32-arm64-msvc@1.31.1: 6273 6350 resolution: ··· 7910 7987 integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, 7911 7988 } 7912 7989 engines: { node: ">= 0.4" } 7990 + 7991 + swr@2.4.1: 7992 + resolution: 7993 + { 7994 + integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==, 7995 + } 7996 + peerDependencies: 7997 + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 7913 7998 7914 7999 tagged-tag@1.0.0: 7915 8000 resolution: ··· 11443 11528 object-keys: 1.1.1 11444 11529 11445 11530 depd@2.0.0: {} 11531 + 11532 + dequal@2.0.3: {} 11446 11533 11447 11534 detect-libc@2.1.2: {} 11448 11535 ··· 11641 11728 "@next/eslint-plugin-next": 16.1.7 11642 11729 eslint: 9.39.3(jiti@2.6.1) 11643 11730 eslint-import-resolver-node: 0.3.9 11644 - 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)))(eslint@9.39.3(jiti@2.6.1)) 11731 + 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)) 11645 11732 eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) 11646 11733 eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) 11647 11734 eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) ··· 11675 11762 transitivePeerDependencies: 11676 11763 - supports-color 11677 11764 11678 - 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)))(eslint@9.39.3(jiti@2.6.1)): 11765 + 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)): 11679 11766 dependencies: 11680 11767 "@nolyfill/is-core-module": 1.0.39 11681 11768 debug: 4.4.3 ··· 11697 11784 optionalDependencies: 11698 11785 eslint: 9.39.3(jiti@2.6.1) 11699 11786 eslint-import-resolver-node: 0.3.9 11700 - 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)))(eslint@9.39.3(jiti@2.6.1)) 11787 + 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)) 11701 11788 transitivePeerDependencies: 11702 11789 - supports-color 11703 11790 ··· 13760 13847 has-flag: 4.0.0 13761 13848 13762 13849 supports-preserve-symlinks-flag@1.0.0: {} 13850 + 13851 + swr@2.4.1(react@19.2.4): 13852 + dependencies: 13853 + dequal: 2.0.3 13854 + react: 19.2.4 13855 + use-sync-external-store: 1.6.0(react@19.2.4) 13763 13856 13764 13857 tagged-tag@1.0.0: {} 13765 13858