audio streaming app plyr.fm
38
fork

Configure Feed

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

revert: roll back liker avatar strip redesign (#1309)

Restores the prior hover-tooltip + mobile-sheet model for liker
lists. The inline avatar strip was fighting too many surfaces
(track rows, cards, detail page, click propagation to play button,
SvelteKit nav hijacking) and broke enough of them that the churn
outweighed the UX win.

Reverts PRs #1302, #1303, #1304, #1305, #1306, #1307, #1308 in
one commit. Search modal stability (#1301) is preserved.

Removed by this revert:
- AvatarStack.svelte, LikersStrip.svelte (never existed before)
- LikerPreview schema, get_top_likers aggregation, top_likers on
TrackResponse, all the callsite wiring

Restored by this revert:
- LikersTooltip.svelte (desktop hover tooltip)
- LikersSheet.svelte + likers-sheet.svelte.ts (mobile bottom sheet)
- LikersSheet mount in +layout.svelte
- Original .likes span markup + CSS in TrackItem, TrackCard,
track/[id]/+page.svelte
- Original supporter-circle markup + CSS on u/[handle]/+page.svelte

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
1dd1fa60 47bed2de

+1084 -962
+2 -5
backend/src/backend/api/albums/listing.py
··· 17 17 from backend.utilities.aggregations import ( 18 18 get_comment_counts, 19 19 get_like_counts, 20 - get_top_likers, 21 20 get_track_tags, 22 21 ) 23 22 from backend.utilities.redis import get_async_redis_client ··· 189 188 190 189 # batch fetch aggregations 191 190 if track_ids: 192 - like_counts, comment_counts, track_tags, top_likers = await asyncio.gather( 191 + like_counts, comment_counts, track_tags = await asyncio.gather( 193 192 get_like_counts(db, track_ids), 194 193 get_comment_counts(db, track_ids), 195 194 get_track_tags(db, track_ids), 196 - get_top_likers(db, track_ids), 197 195 ) 198 196 else: 199 - like_counts, comment_counts, track_tags, top_likers = {}, {}, {}, {} 197 + like_counts, comment_counts, track_tags = {}, {}, {} 200 198 201 199 # get authenticated user's likes for this album's tracks only 202 200 liked_track_ids: set[int] | None = None ··· 220 218 like_counts, 221 219 comment_counts, 222 220 track_tags=track_tags, 223 - top_likers=top_likers, 224 221 ) 225 222 for track in tracks 226 223 ]
+1 -4
backend/src/backend/api/for_you.py
··· 44 44 from backend.utilities.aggregations import ( 45 45 get_comment_counts, 46 46 get_like_counts, 47 - get_top_likers, 48 47 get_track_tags, 49 48 ) 50 49 from backend.utilities.tags import DEFAULT_HIDDEN_TAGS ··· 386 385 ) 387 386 liked_track_ids = set(liked_result.scalars().all()) 388 387 389 - like_counts, comment_counts, track_tags, top_likers = await asyncio.gather( 388 + like_counts, comment_counts, track_tags = await asyncio.gather( 390 389 get_like_counts(db, page_ids), 391 390 get_comment_counts(db, page_ids), 392 391 get_track_tags(db, page_ids), 393 - get_top_likers(db, page_ids), 394 392 ) 395 393 396 394 gated_artist_dids = { ··· 410 408 like_counts=like_counts, 411 409 comment_counts=comment_counts, 412 410 track_tags=track_tags, 413 - top_likers=top_likers, 414 411 viewer_did=actor_did, 415 412 supported_artist_dids=supported_artist_dids, 416 413 )
+3 -16
backend/src/backend/api/lists/hydration.py
··· 1 1 """shared track hydration for list-backed endpoints (playlists, liked lists).""" 2 2 3 - import asyncio 4 - 5 3 from sqlalchemy import select 6 4 from sqlalchemy.ext.asyncio import AsyncSession 7 5 from sqlalchemy.orm import selectinload 8 6 9 7 from backend.models import Track, TrackLike 10 8 from backend.schemas import TrackResponse 11 - from backend.utilities.aggregations import ( 12 - get_comment_counts, 13 - get_like_counts, 14 - get_top_likers, 15 - ) 9 + from backend.utilities.aggregations import get_comment_counts, get_like_counts 16 10 17 11 18 12 async def hydrate_tracks_from_uris( ··· 37 31 track_by_uri = {t.atproto_record_uri: t for t in all_tracks} 38 32 39 33 track_ids = [t.id for t in all_tracks] 40 - if track_ids: 41 - like_counts, comment_counts, top_likers = await asyncio.gather( 42 - get_like_counts(db, track_ids), 43 - get_comment_counts(db, track_ids), 44 - get_top_likers(db, track_ids), 45 - ) 46 - else: 47 - like_counts, comment_counts, top_likers = {}, {}, {} 34 + like_counts = await get_like_counts(db, track_ids) if track_ids else {} 35 + comment_counts = await get_comment_counts(db, track_ids) if track_ids else {} 48 36 49 37 liked_track_ids: set[int] = set() 50 38 if session_did and track_ids: ··· 65 53 liked_track_ids=liked_track_ids, 66 54 like_counts=like_counts, 67 55 comment_counts=comment_counts, 68 - top_likers=top_likers, 69 56 ) 70 57 tracks.append(track_response) 71 58
+2 -8
backend/src/backend/api/tracks/likes.py
··· 18 18 ) 19 19 from backend.models import Artist, Track, TrackLike, get_db 20 20 from backend.schemas import LikedResponse, TrackResponse 21 - from backend.utilities.aggregations import ( 22 - get_comment_counts, 23 - get_like_counts, 24 - get_top_likers, 25 - ) 21 + from backend.utilities.aggregations import get_comment_counts, get_like_counts 26 22 27 23 from .router import router 28 24 ··· 72 68 73 69 liked_track_ids = {track.id for track in tracks} 74 70 track_ids = [track.id for track in tracks] 75 - like_counts, comment_counts, top_likers = await asyncio.gather( 71 + like_counts, comment_counts = await asyncio.gather( 76 72 get_like_counts(db, track_ids), 77 73 get_comment_counts(db, track_ids), 78 - get_top_likers(db, track_ids), 79 74 ) 80 75 81 76 track_responses = await asyncio.gather( ··· 85 80 liked_track_ids=liked_track_ids, 86 81 like_counts=like_counts, 87 82 comment_counts=comment_counts, 88 - top_likers=top_likers, 89 83 ) 90 84 for track in tracks 91 85 ]
+6 -19
backend/src/backend/api/tracks/listing.py
··· 33 33 get_comment_counts, 34 34 get_copyright_info, 35 35 get_like_counts, 36 - get_top_likers, 37 36 get_top_tracks_with_counts, 38 37 get_track_tags, 39 38 ) ··· 212 211 # to the moderation service and is only displayed in /tracks/me (artist portal) 213 212 track_ids = [track.id for track in tracks] 214 213 with logfire.span("batch aggregations", track_count=len(track_ids)): 215 - like_counts, comment_counts, track_tags, top_likers = await asyncio.gather( 214 + like_counts, comment_counts, track_tags = await asyncio.gather( 216 215 get_like_counts(db, track_ids), 217 216 get_comment_counts(db, track_ids), 218 217 get_track_tags(db, track_ids), 219 - get_top_likers(db, track_ids), 220 218 ) 221 219 222 220 # use cached PDS URLs with fallback on failure ··· 309 307 like_counts, 310 308 comment_counts, 311 309 track_tags=track_tags, 312 - top_likers=top_likers, 313 310 viewer_did=viewer_did, 314 311 supported_artist_dids=supported_artist_dids, 315 312 ) ··· 394 391 395 392 # batch fetch aggregations — always use all-time like counts for display 396 393 # (period filtering only affects which tracks appear and their ordering) 397 - like_counts, comment_counts, track_tags, top_likers = await asyncio.gather( 394 + like_counts, comment_counts, track_tags = await asyncio.gather( 398 395 get_like_counts(db, track_ids), 399 396 get_comment_counts(db, track_ids), 400 397 get_track_tags(db, track_ids), 401 - get_top_likers(db, track_ids), 402 398 ) 403 399 404 400 # resolve supporter status for gated content ··· 424 420 like_counts=like_counts, 425 421 comment_counts=comment_counts, 426 422 track_tags=track_tags, 427 - top_likers=top_likers, 428 423 viewer_did=viewer_did, 429 424 supported_artist_dids=supported_artist_dids, 430 425 ) ··· 464 459 465 460 # batch fetch copyright info and tags 466 461 track_ids = [track.id for track in tracks] 467 - copyright_info, track_tags, top_likers = await asyncio.gather( 462 + copyright_info, track_tags = await asyncio.gather( 468 463 get_copyright_info(db, track_ids), 469 464 get_track_tags(db, track_ids), 470 - get_top_likers(db, track_ids), 471 465 ) 472 466 473 467 # fetch all track responses concurrently 474 468 track_responses = await asyncio.gather( 475 469 *[ 476 470 TrackResponse.from_track( 477 - track, 478 - copyright_info=copyright_info, 479 - track_tags=track_tags, 480 - top_likers=top_likers, 471 + track, copyright_info=copyright_info, track_tags=track_tags 481 472 ) 482 473 for track in tracks 483 474 ] ··· 521 512 522 513 # batch fetch copyright info and tags 523 514 track_ids = [track.id for track in tracks] 524 - copyright_info, track_tags, top_likers = await asyncio.gather( 515 + copyright_info, track_tags = await asyncio.gather( 525 516 get_copyright_info(db, track_ids), 526 517 get_track_tags(db, track_ids), 527 - get_top_likers(db, track_ids), 528 518 ) 529 519 530 520 # fetch all track responses concurrently 531 521 track_responses = await asyncio.gather( 532 522 *[ 533 523 TrackResponse.from_track( 534 - track, 535 - copyright_info=copyright_info, 536 - track_tags=track_tags, 537 - top_likers=top_likers, 524 + track, copyright_info=copyright_info, track_tags=track_tags 538 525 ) 539 526 for track in tracks 540 527 ]
+4 -8
backend/src/backend/api/tracks/mutations.py
··· 37 37 from backend.models import Artist, Track, TrackTag, get_db 38 38 from backend.schemas import MessageResponse, TrackResponse 39 39 from backend.storage import storage 40 - from backend.utilities.aggregations import get_top_likers, get_track_tags 41 40 from backend.utilities.tags import get_or_create_tag, parse_tags_json 42 41 43 42 from .metadata_service import ( ··· 376 375 if tags is not None: 377 376 track_tags_dict = {track.id: updated_tags} 378 377 else: 379 - track_tags_dict = await get_track_tags(db, [track.id]) 378 + from backend.utilities.aggregations import get_track_tags 380 379 381 - top_likers = await get_top_likers(db, [track.id]) 380 + track_tags_dict = await get_track_tags(db, [track.id]) 382 381 383 - return await TrackResponse.from_track( 384 - track, track_tags=track_tags_dict, top_likers=top_likers 385 - ) 382 + return await TrackResponse.from_track(track, track_tags=track_tags_dict) 386 383 387 384 388 385 async def _update_atproto_record( ··· 612 609 613 610 logger.info(f"restored ATProto record for track {track_id}: {new_uri}") 614 611 615 - top_likers = await get_top_likers(db, [track.id]) 616 612 return RestoreRecordResponse( 617 613 success=True, 618 - track=await TrackResponse.from_track(track, top_likers=top_likers), 614 + track=await TrackResponse.from_track(track), 619 615 restored_uri=new_uri, 620 616 ) 621 617
+2 -8
backend/src/backend/api/tracks/playback.py
··· 24 24 get_db, 25 25 ) 26 26 from backend.schemas import PlayCountResponse, TrackResponse 27 - from backend.utilities.aggregations import ( 28 - get_like_counts, 29 - get_top_likers, 30 - get_track_tags, 31 - ) 27 + from backend.utilities.aggregations import get_like_counts, get_track_tags 32 28 33 29 from .router import router 34 30 ··· 55 51 ): 56 52 liked_track_ids = {track.id} 57 53 58 - like_counts, track_tags, top_likers = await asyncio.gather( 54 + like_counts, track_tags = await asyncio.gather( 59 55 get_like_counts(db, [track.id]), 60 56 get_track_tags(db, [track.id]), 61 - get_top_likers(db, [track.id]), 62 57 ) 63 58 64 59 return await TrackResponse.from_track( ··· 66 61 liked_track_ids=liked_track_ids, 67 62 like_counts=like_counts, 68 63 track_tags=track_tags, 69 - top_likers=top_likers, 70 64 ) 71 65 72 66
+1 -7
backend/src/backend/api/tracks/shares.py
··· 14 14 from backend.config import settings 15 15 from backend.models import Artist, ShareLink, ShareLinkEvent, Track, get_db 16 16 from backend.schemas import OkResponse, TrackResponse 17 - from backend.utilities.aggregations import get_top_likers 18 17 19 18 from .router import router 20 19 ··· 235 234 236 235 return users, anonymous_count or 0 237 236 238 - # batch-preload top likers for every share's track in one query 239 - top_likers = await get_top_likers(db, [sl.track_id for sl in share_links]) 240 - 241 237 shares = [] 242 238 for share_link in share_links: 243 239 # get visitor stats (clicks) ··· 251 247 play_count = len(listeners) + anonymous_plays 252 248 253 249 # build track response 254 - track_response = await TrackResponse.from_track( 255 - share_link.track, top_likers=top_likers 256 - ) 250 + track_response = await TrackResponse.from_track(share_link.track) 257 251 258 252 shares.append( 259 253 ShareLinkStats(
+1 -4
backend/src/backend/api/tracks/tags.py
··· 18 18 from backend.utilities.aggregations import ( 19 19 get_comment_counts, 20 20 get_like_counts, 21 - get_top_likers, 22 21 get_track_tags, 23 22 ) 24 23 ··· 94 93 # batch fetch like counts, comment counts, and tags 95 94 # note: copyright_info excluded - only needed in artist portal (/tracks/me) 96 95 track_ids = [track.id for track in tracks] 97 - like_counts, comment_counts, track_tags_map, top_likers = await asyncio.gather( 96 + like_counts, comment_counts, track_tags_map = await asyncio.gather( 98 97 get_like_counts(db, track_ids), 99 98 get_comment_counts(db, track_ids), 100 99 get_track_tags(db, track_ids), 101 - get_top_likers(db, track_ids), 102 100 ) 103 101 104 102 # build track responses ··· 110 108 like_counts=like_counts, 111 109 comment_counts=comment_counts, 112 110 track_tags=track_tags_map, 113 - top_likers=top_likers, 114 111 ) 115 112 for track in tracks 116 113 ]
+2 -8
backend/src/backend/api/users.py
··· 12 12 from backend._internal import Session, get_optional_session 13 13 from backend.models import Artist, Track, TrackLike, get_db 14 14 from backend.schemas import TrackResponse 15 - from backend.utilities.aggregations import ( 16 - get_comment_counts, 17 - get_like_counts, 18 - get_top_likers, 19 - ) 15 + from backend.utilities.aggregations import get_comment_counts, get_like_counts 20 16 21 17 router = APIRouter(prefix="/users", tags=["users"]) 22 18 ··· 77 73 liked_track_ids = set(liked_result.scalars().all()) 78 74 79 75 track_ids = [track.id for track in tracks] 80 - like_counts, comment_counts, top_likers = await asyncio.gather( 76 + like_counts, comment_counts = await asyncio.gather( 81 77 get_like_counts(db, track_ids), 82 78 get_comment_counts(db, track_ids), 83 - get_top_likers(db, track_ids), 84 79 ) 85 80 86 81 track_responses = await asyncio.gather( ··· 90 85 liked_track_ids=liked_track_ids, 91 86 like_counts=like_counts, 92 87 comment_counts=comment_counts, 93 - top_likers=top_likers, 94 88 ) 95 89 for track in tracks 96 90 ]
+1 -11
backend/src/backend/schemas.py
··· 6 6 7 7 from backend._internal.atproto.client import parse_at_uri 8 8 from backend.models import Album, Track 9 - from backend.utilities.aggregations import CopyrightInfo, LikerPreview 9 + from backend.utilities.aggregations import CopyrightInfo 10 10 11 11 # --- common simple response types --- 12 12 ··· 123 123 audio_storage: str = "r2" # "r2" | "pds" | "both" 124 124 pds_blob_cid: str | None = None # CID if stored on user's PDS 125 125 unlisted: bool = False # excluded from discovery feeds 126 - top_likers: list[LikerPreview] = Field(default_factory=list) 127 - # ^ up to 3 most recent likers, for the inline avatar stack. empty when 128 - # the track has no likes OR the callsite didn't batch-load likers. 129 126 130 127 @classmethod 131 128 async def from_track( ··· 137 134 comment_counts: dict[int, int] | None = None, 138 135 copyright_info: dict[int, CopyrightInfo] | None = None, 139 136 track_tags: dict[int, set[str]] | None = None, 140 - top_likers: dict[int, list[LikerPreview]] | None = None, 141 137 viewer_did: str | None = None, 142 138 supported_artist_dids: set[str] | None = None, 143 139 ) -> "TrackResponse": ··· 151 147 comment_counts: optional dict of track_id -> comment_count 152 148 copyright_info: optional dict of track_id -> CopyrightInfo 153 149 track_tags: optional dict of track_id -> set of tag names 154 - top_likers: optional dict of track_id -> list of LikerPreview 155 - (most recent first, capped at 3) 156 150 viewer_did: optional DID of the viewer (for gated content resolution) 157 151 supported_artist_dids: optional set of artist DIDs the viewer supports 158 152 """ ··· 161 155 162 156 # get like count 163 157 like_count = like_counts.get(track.id, 0) if like_counts else 0 164 - 165 - # get top likers (preview for the inline avatar stack) 166 - track_top_likers = top_likers.get(track.id, []) if top_likers else [] 167 158 168 159 # get comment count 169 160 comment_count = comment_counts.get(track.id, 0) if comment_counts else 0 ··· 231 222 thumbnail_url=track.thumbnail_url, 232 223 is_liked=is_liked, 233 224 like_count=like_count, 234 - top_likers=track_top_likers, 235 225 comment_count=comment_count, 236 226 album=album_data, 237 227 tags=tags,
+2 -110
backend/src/backend/utilities/aggregations.py
··· 1 1 """aggregation utilities for efficient batch counting.""" 2 2 3 3 import logging 4 - from collections import Counter, defaultdict 4 + from collections import Counter 5 5 from dataclasses import dataclass 6 6 from datetime import datetime 7 7 from typing import Any 8 8 9 - from pydantic import BaseModel 10 9 from sqlalchemy import select 11 10 from sqlalchemy.ext.asyncio import AsyncSession 12 11 from sqlalchemy.sql import func 13 12 14 - from backend.models import ( 15 - Artist, 16 - CopyrightScan, 17 - Tag, 18 - Track, 19 - TrackComment, 20 - TrackLike, 21 - TrackTag, 22 - ) 13 + from backend.models import CopyrightScan, Tag, Track, TrackComment, TrackLike, TrackTag 23 14 24 15 logger = logging.getLogger(__name__) 25 16 ··· 32 23 primary_match: str | None = None # "Title by Artist" for most frequent match 33 24 34 25 35 - class LikerPreview(BaseModel): 36 - """lightweight liker preview embedded in track responses. 37 - 38 - used to render the overlapping avatar stack next to a track's like count 39 - without a follow-up request. includes `liked_at` so the per-avatar hover 40 - tooltip can show "display name · 2h ago" without a follow-up fetch. 41 - 42 - defined here (alongside aggregation helpers) rather than in `schemas.py` 43 - to avoid a circular import: `schemas.py` imports from `aggregations.py`. 44 - """ 45 - 46 - did: str 47 - handle: str 48 - display_name: str | None 49 - avatar_url: str | None 50 - liked_at: str 51 - 52 - 53 26 async def get_like_counts(db: AsyncSession, track_ids: list[int]) -> dict[int, int]: 54 27 """get like counts for multiple tracks in a single query. 55 28 ··· 72 45 73 46 result = await db.execute(stmt) 74 47 return dict(result.all()) 75 - 76 - 77 - async def get_top_likers( 78 - db: AsyncSession, 79 - track_ids: list[int], 80 - limit: int = 5, 81 - ) -> dict[int, list[LikerPreview]]: 82 - """get the N most recent likers per track in a single batched query. 83 - 84 - uses ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY created_at DESC) and 85 - filters to rn <= limit. postgres 15+ pushes the limit condition into the 86 - window aggregate (Run Condition), so work short-circuits once each track 87 - has N rows. EXPLAIN ANALYZE on production (308 likes, 20-track page): 88 - ~1ms execution, all in shared buffer cache. 89 - 90 - args: 91 - db: database session 92 - track_ids: list of track IDs to get likers for 93 - limit: max likers per track. default 5 — the frontend displays 3 94 - inline but only shows a "+N" expand tile when the overflow is 95 - meaningful (3+). returning 5 means tracks with 4 or 5 total 96 - likes can render everyone inline without the dead-end "+1"/"+2" 97 - affordance. 98 - 99 - returns: 100 - dict mapping track_id -> list of LikerPreview, most recent first. 101 - tracks with zero likes are omitted from the dict. 102 - """ 103 - if not track_ids: 104 - return {} 105 - 106 - rn = ( 107 - func.row_number() 108 - .over( 109 - partition_by=TrackLike.track_id, 110 - order_by=TrackLike.created_at.desc(), 111 - ) 112 - .label("rn") 113 - ) 114 - 115 - ranked = ( 116 - select( 117 - Artist.did.label("did"), 118 - Artist.handle.label("handle"), 119 - Artist.display_name.label("display_name"), 120 - Artist.avatar_url.label("avatar_url"), 121 - TrackLike.track_id.label("track_id"), 122 - TrackLike.created_at.label("liked_at"), 123 - rn, 124 - ) 125 - .join(Artist, Artist.did == TrackLike.user_did) 126 - .where(TrackLike.track_id.in_(track_ids)) 127 - .subquery() 128 - ) 129 - 130 - stmt = ( 131 - select( 132 - ranked.c.did, 133 - ranked.c.handle, 134 - ranked.c.display_name, 135 - ranked.c.avatar_url, 136 - ranked.c.track_id, 137 - ranked.c.liked_at, 138 - ) 139 - .where(ranked.c.rn <= limit) 140 - .order_by(ranked.c.track_id, ranked.c.rn) 141 - ) 142 - 143 - result = await db.execute(stmt) 144 - out: dict[int, list[LikerPreview]] = defaultdict(list) 145 - for did, handle, display_name, avatar_url, track_id, liked_at in result.all(): 146 - out[track_id].append( 147 - LikerPreview( 148 - did=did, 149 - handle=handle, 150 - display_name=display_name, 151 - avatar_url=avatar_url, 152 - liked_at=liked_at.isoformat(), 153 - ) 154 - ) 155 - return dict(out) 156 48 157 49 158 50 async def get_comment_counts(db: AsyncSession, track_ids: list[int]) -> dict[int, int]:
-189
backend/tests/utilities/test_aggregations.py
··· 7 7 from backend.utilities.aggregations import ( 8 8 get_copyright_info, 9 9 get_like_counts, 10 - get_top_likers, 11 10 get_top_track_ids, 12 11 get_top_tracks_with_counts, 13 12 ) ··· 208 207 209 208 result = await get_top_tracks_with_counts(db_session) 210 209 assert result == [] 211 - 212 - 213 - # tests for get_top_likers 214 - 215 - 216 - @pytest.fixture 217 - async def test_tracks_with_liker_artists(db_session: AsyncSession) -> list[Track]: 218 - """create test tracks + liker Artist rows so JOIN in get_top_likers resolves. 219 - 220 - track 0: 5 likes (user1..user5), most recent first by insertion order 221 - track 1: 2 likes (user1, user2) 222 - track 2: 0 likes 223 - """ 224 - uploader = Artist( 225 - did="did:plc:uploader", 226 - handle="uploader.bsky.social", 227 - display_name="Uploader", 228 - ) 229 - db_session.add(uploader) 230 - 231 - liker_artists = [ 232 - Artist( 233 - did=f"did:plc:liker{i}", 234 - handle=f"liker{i}.bsky.social", 235 - display_name=f"Liker {i}", 236 - avatar_url=f"https://example.com/avatar{i}.jpg", 237 - ) 238 - for i in range(1, 6) 239 - ] 240 - db_session.add_all(liker_artists) 241 - await db_session.flush() 242 - 243 - tracks = [] 244 - for i in range(3): 245 - track = Track( 246 - title=f"Track {i}", 247 - artist_did=uploader.did, 248 - file_id=f"liker_file_{i}", 249 - file_type="mp3", 250 - atproto_record_uri=f"at://did:plc:uploader/fm.plyr.track/{i}", 251 - atproto_record_cid=f"cid_{i}", 252 - ) 253 - db_session.add(track) 254 - tracks.append(track) 255 - 256 - await db_session.commit() 257 - for t in tracks: 258 - await db_session.refresh(t) 259 - 260 - # insert likes in chronological order. default created_at timestamps will 261 - # order them by insertion — each subsequent commit is "more recent". 262 - import asyncio as _asyncio 263 - 264 - # track 0: 5 likes 265 - for i in range(1, 6): 266 - db_session.add( 267 - TrackLike( 268 - track_id=tracks[0].id, 269 - user_did=f"did:plc:liker{i}", 270 - atproto_like_uri=f"at://did:plc:liker{i}/fm.plyr.like/t0-{i}", 271 - ) 272 - ) 273 - await db_session.commit() 274 - # ensure monotonically increasing created_at across likes so ordering 275 - # is deterministic without having to stamp timestamps manually 276 - await _asyncio.sleep(0.01) 277 - 278 - # track 1: 2 likes 279 - for i in (1, 2): 280 - db_session.add( 281 - TrackLike( 282 - track_id=tracks[1].id, 283 - user_did=f"did:plc:liker{i}", 284 - atproto_like_uri=f"at://did:plc:liker{i}/fm.plyr.like/t1-{i}", 285 - ) 286 - ) 287 - await db_session.commit() 288 - await _asyncio.sleep(0.01) 289 - 290 - return tracks 291 - 292 - 293 - async def test_get_top_likers_empty_list(db_session: AsyncSession): 294 - """empty track_ids list returns empty dict.""" 295 - result = await get_top_likers(db_session, []) 296 - assert result == {} 297 - 298 - 299 - async def test_get_top_likers_default_limit( 300 - db_session: AsyncSession, test_tracks_with_liker_artists: list[Track] 301 - ): 302 - """default limit=5 returns at most 5 likers per track, most recent first. 303 - 304 - the frontend displays 3 inline but the backend returns 5 so tracks with 305 - 4 or 5 total likes can render everyone without a dead-end "+1"/"+2" tile. 306 - """ 307 - tracks = test_tracks_with_liker_artists 308 - result = await get_top_likers(db_session, [t.id for t in tracks]) 309 - 310 - # track 0 has exactly 5 likes → returns all 5 most recent 311 - assert len(result[tracks[0].id]) == 5 312 - assert [liker.handle for liker in result[tracks[0].id]] == [ 313 - "liker5.bsky.social", 314 - "liker4.bsky.social", 315 - "liker3.bsky.social", 316 - "liker2.bsky.social", 317 - "liker1.bsky.social", 318 - ] 319 - 320 - # track 1 has 2 likes → returns both (liker2, liker1) 321 - assert len(result[tracks[1].id]) == 2 322 - assert [liker.handle for liker in result[tracks[1].id]] == [ 323 - "liker2.bsky.social", 324 - "liker1.bsky.social", 325 - ] 326 - 327 - # track 2 has no likes → not in the dict 328 - assert tracks[2].id not in result 329 - 330 - 331 - async def test_get_top_likers_preview_fields( 332 - db_session: AsyncSession, test_tracks_with_liker_artists: list[Track] 333 - ): 334 - """LikerPreview has did, handle, display_name, avatar_url, liked_at.""" 335 - tracks = test_tracks_with_liker_artists 336 - result = await get_top_likers(db_session, [tracks[0].id]) 337 - 338 - liker = result[tracks[0].id][0] 339 - assert liker.did.startswith("did:plc:liker") 340 - assert liker.handle.endswith(".bsky.social") 341 - assert liker.display_name is not None 342 - assert liker.avatar_url is not None 343 - # liked_at is included so the hover tooltip can show "name · 2h ago" 344 - # without a follow-up fetch 345 - assert liker.liked_at # ISO-formatted timestamp string 346 - assert "T" in liker.liked_at # crude format check 347 - 348 - 349 - async def test_get_top_likers_respects_custom_limit( 350 - db_session: AsyncSession, test_tracks_with_liker_artists: list[Track] 351 - ): 352 - """limit=1 returns only the single most recent liker per track.""" 353 - tracks = test_tracks_with_liker_artists 354 - result = await get_top_likers(db_session, [tracks[0].id], limit=1) 355 - assert len(result[tracks[0].id]) == 1 356 - assert result[tracks[0].id][0].handle == "liker5.bsky.social" 357 - 358 - 359 - async def test_get_top_likers_skips_likers_without_artist_row( 360 - db_session: AsyncSession, 361 - ): 362 - """likers whose user_did has no Artist row are omitted (JOIN requirement). 363 - 364 - this matches the existing `GET /tracks/{id}/likes` behavior — it also 365 - requires an Artist join and skips orphan likes. 366 - """ 367 - uploader = Artist( 368 - did="did:plc:orphan_test_uploader", 369 - handle="orphan-uploader.bsky.social", 370 - display_name="Orphan Test Uploader", 371 - ) 372 - db_session.add(uploader) 373 - await db_session.flush() 374 - 375 - track = Track( 376 - title="Orphan Test Track", 377 - artist_did=uploader.did, 378 - file_id="orphan_file", 379 - file_type="mp3", 380 - atproto_record_uri="at://did:plc:orphan_test_uploader/fm.plyr.track/1", 381 - atproto_record_cid="cid_orphan", 382 - ) 383 - db_session.add(track) 384 - await db_session.commit() 385 - await db_session.refresh(track) 386 - 387 - # like from a DID that has no Artist row 388 - db_session.add( 389 - TrackLike( 390 - track_id=track.id, 391 - user_did="did:plc:unknown_user", 392 - atproto_like_uri="at://did:plc:unknown_user/fm.plyr.like/1", 393 - ) 394 - ) 395 - await db_session.commit() 396 - 397 - result = await get_top_likers(db_session, [track.id]) 398 - assert track.id not in result # orphan filtered out, no matches remain 399 210 400 211 401 212 # tests for get_copyright_info
-263
frontend/src/lib/components/AvatarStack.svelte
··· 1 - <script lang="ts"> 2 - import type { UserPreview } from '$lib/types'; 3 - 4 - interface Props { 5 - /** users to render, already sorted (most-relevant first). */ 6 - users: UserPreview[]; 7 - /** total number of users this stack represents (for the "+N" overflow). */ 8 - total: number; 9 - /** max avatars to render before overflowing to "+N". default 3. */ 10 - maxVisible?: number; 11 - /** minimum overflow size that justifies a "+N" tile. if the overflow 12 - * would be smaller than this, just render everyone inline — clicking 13 - * "+1" to reveal a single extra avatar is ridiculous and expanding 14 - * scrolls nowhere. default 3. */ 15 - minOverflow?: number; 16 - /** avatar diameter in pixels. default 24. */ 17 - size?: number; 18 - /** CSS color for the hairline ring separating stacked avatars. 19 - * must match the background the stack sits on, or the overlap looks 20 - * muddy. default `var(--bg-secondary)`. */ 21 - borderColor?: string; 22 - /** URL for the "+N" tile to link to (e.g. external supporter list). */ 23 - moreHref?: string; 24 - /** target attribute for `moreHref`, when set. */ 25 - moreTarget?: '_blank' | '_self'; 26 - /** handler for clicks on the "+N" tile (mutually exclusive with moreHref). */ 27 - onMoreClick?: (e: MouseEvent | KeyboardEvent) => void; 28 - /** build a per-user link target. if omitted, avatars render as spans. */ 29 - avatarHref?: (u: UserPreview) => string; 30 - /** handler for clicks on individual avatars (e.g. stop propagation). */ 31 - onAvatarClick?: (u: UserPreview, e: MouseEvent) => void; 32 - /** build the hover/focus title shown for each avatar. defaults to 33 - * `display_name || handle`. likers surfaces pass a formatter that 34 - * appends the relative `liked_at` timestamp. */ 35 - avatarTitle?: (u: UserPreview) => string; 36 - /** accessible label for the strip (e.g. "21 likes"). */ 37 - ariaLabel?: string; 38 - /** extra class on the container, for site-specific tweaks. */ 39 - class?: string; 40 - /** if true, the stack becomes horizontally scrollable within `maxScrollWidth` 41 - * and the overlap is slightly reduced so individual avatars are easier 42 - * to tap. use for the "expanded" state when the caller has loaded the 43 - * full liker/supporter list. */ 44 - scrollable?: boolean; 45 - /** max width of the stack container in scrollable mode. default `20rem`. */ 46 - maxScrollWidth?: string; 47 - } 48 - 49 - let { 50 - users, 51 - total, 52 - maxVisible = 3, 53 - minOverflow = 3, 54 - size = 24, 55 - borderColor = 'var(--bg-secondary)', 56 - moreHref, 57 - moreTarget = '_self', 58 - onMoreClick, 59 - avatarHref, 60 - onAvatarClick, 61 - avatarTitle, 62 - ariaLabel, 63 - class: klass = '', 64 - scrollable = false, 65 - maxScrollWidth = '20rem' 66 - }: Props = $props(); 67 - 68 - function resolveTitle(u: UserPreview): string { 69 - if (avatarTitle) return avatarTitle(u); 70 - return u.display_name || u.handle; 71 - } 72 - 73 - // if the overflow would be tiny, show everyone — "+1" (or "+2") next to 74 - // three avatars is a dead-end affordance: clicking it scrolls nowhere. 75 - // only skip the +N tile when we actually have enough users loaded to 76 - // fill in the overflow; otherwise we'd render 3 and pretend that's all. 77 - let effectiveMaxVisible = $derived.by(() => { 78 - const wouldOverflow = Math.max(0, total - maxVisible); 79 - const haveEveryone = users.length >= total; 80 - if (wouldOverflow > 0 && wouldOverflow < minOverflow && haveEveryone) { 81 - return total; 82 - } 83 - return maxVisible; 84 - }); 85 - let visible = $derived(users.slice(0, effectiveMaxVisible)); 86 - let overflow = $derived(Math.max(0, total - visible.length)); 87 - // overlap grows with size so the visual density stays consistent. 88 - let overlap = $derived(Math.round(size / 4)); 89 - 90 - function fallbackInitial(u: UserPreview): string { 91 - return (u.display_name || u.handle || '?').charAt(0).toUpperCase(); 92 - } 93 - 94 - function handleMoreKeydown(e: KeyboardEvent) { 95 - if (e.key === 'Enter' || e.key === ' ') { 96 - e.preventDefault(); 97 - onMoreClick?.(e); 98 - } 99 - } 100 - </script> 101 - 102 - <span 103 - class="avatar-stack {klass}" 104 - class:scrollable 105 - role={ariaLabel ? 'group' : undefined} 106 - aria-label={ariaLabel} 107 - style="--stack-size: {size}px; --stack-overlap: {overlap}px; --stack-border: {borderColor}; --stack-max-width: {maxScrollWidth};" 108 - > 109 - {#each visible as user (user.did)} 110 - {@const title = resolveTitle(user)} 111 - {#if avatarHref} 112 - <a 113 - class="avatar" 114 - href={avatarHref(user)} 115 - {title} 116 - onclick={(e) => onAvatarClick?.(user, e)} 117 - > 118 - {#if user.avatar_url} 119 - <img src={user.avatar_url} alt="" loading="lazy" /> 120 - {:else} 121 - <span class="fallback">{fallbackInitial(user)}</span> 122 - {/if} 123 - </a> 124 - {:else} 125 - <span class="avatar static" {title}> 126 - {#if user.avatar_url} 127 - <img src={user.avatar_url} alt="" loading="lazy" /> 128 - {:else} 129 - <span class="fallback">{fallbackInitial(user)}</span> 130 - {/if} 131 - </span> 132 - {/if} 133 - {/each} 134 - {#if overflow > 0} 135 - {#if moreHref} 136 - <a 137 - class="avatar more" 138 - href={moreHref} 139 - target={moreTarget} 140 - rel={moreTarget === '_blank' ? 'noopener' : undefined} 141 - title={ariaLabel ?? `${overflow} more`} 142 - >+{overflow}</a> 143 - {:else if onMoreClick} 144 - <span 145 - class="avatar more" 146 - role="button" 147 - tabindex="0" 148 - title={ariaLabel ?? `${overflow} more`} 149 - onclick={(e) => onMoreClick(e)} 150 - onkeydown={handleMoreKeydown} 151 - >+{overflow}</span> 152 - {:else} 153 - <span class="avatar more" title={ariaLabel ?? `${overflow} more`}>+{overflow}</span> 154 - {/if} 155 - {/if} 156 - </span> 157 - 158 - <style> 159 - .avatar-stack { 160 - display: inline-flex; 161 - align-items: center; 162 - vertical-align: middle; 163 - } 164 - 165 - /* expanded mode: horizontal scroll so the full list is reachable without 166 - a separate popover. overlap is preserved so the stack keeps its 167 - visual identity — it doesn't morph into a different widget. */ 168 - .avatar-stack.scrollable { 169 - max-width: var(--stack-max-width); 170 - overflow-x: auto; 171 - overflow-y: visible; 172 - padding: 4px 0; 173 - scrollbar-width: thin; 174 - scrollbar-color: var(--border-default) transparent; 175 - scroll-snap-type: x proximity; 176 - -webkit-overflow-scrolling: touch; 177 - } 178 - 179 - .avatar-stack.scrollable::-webkit-scrollbar { 180 - height: 4px; 181 - } 182 - 183 - .avatar-stack.scrollable::-webkit-scrollbar-track { 184 - background: transparent; 185 - } 186 - 187 - .avatar-stack.scrollable::-webkit-scrollbar-thumb { 188 - background: var(--border-default); 189 - border-radius: 2px; 190 - } 191 - 192 - .avatar-stack.scrollable .avatar { 193 - scroll-snap-align: center; 194 - } 195 - 196 - .avatar { 197 - width: var(--stack-size); 198 - height: var(--stack-size); 199 - border-radius: var(--radius-full); 200 - border: 2px solid var(--stack-border); 201 - background: var(--bg-tertiary); 202 - display: inline-flex; 203 - align-items: center; 204 - justify-content: center; 205 - overflow: hidden; 206 - margin-left: calc(var(--stack-overlap) * -1); 207 - position: relative; 208 - text-decoration: none; 209 - flex-shrink: 0; 210 - color: var(--text-secondary); 211 - transition: 212 - transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), 213 - z-index 0s; 214 - } 215 - 216 - .avatar:first-child { 217 - margin-left: 0; 218 - } 219 - 220 - /* only lift interactive avatars; static ones shouldn't reserve hover focus */ 221 - a.avatar:hover, 222 - a.avatar:focus-visible, 223 - span.avatar.more:hover, 224 - span.avatar.more:focus-visible { 225 - transform: translateY(-2px) scale(1.08); 226 - z-index: 10; 227 - } 228 - 229 - .avatar img { 230 - width: 100%; 231 - height: 100%; 232 - object-fit: cover; 233 - } 234 - 235 - .fallback { 236 - font-size: calc(var(--stack-size) * 0.42); 237 - font-weight: 600; 238 - color: var(--text-secondary); 239 - line-height: 1; 240 - } 241 - 242 - .more { 243 - background: var(--bg-secondary); 244 - font-size: calc(var(--stack-size) * 0.36); 245 - font-weight: 600; 246 - color: var(--text-tertiary); 247 - cursor: pointer; 248 - font-family: inherit; 249 - } 250 - 251 - a.more:hover, 252 - a.more:focus-visible, 253 - span.more:hover, 254 - span.more:focus-visible { 255 - color: var(--accent); 256 - } 257 - 258 - @media (prefers-reduced-motion: reduce) { 259 - .avatar { 260 - transition: none; 261 - } 262 - } 263 - </style>
+315
frontend/src/lib/components/LikersSheet.svelte
··· 1 + <script lang="ts"> 2 + import { likersSheet } from '$lib/likers-sheet.svelte'; 3 + import { getRefreshedAvatar, triggerAvatarRefresh, hasAttemptedRefresh } from '$lib/avatar-refresh.svelte'; 4 + import SensitiveImage from './SensitiveImage.svelte'; 5 + import type { LikerData } from '$lib/tooltip-cache.svelte'; 6 + 7 + let avatarErrors = $state<Set<string>>(new Set()); 8 + 9 + function getDisplayUrl(liker: LikerData): string | null { 10 + return getRefreshedAvatar(liker.did) ?? liker.avatar_url; 11 + } 12 + 13 + function handleAvatarError(did: string) { 14 + avatarErrors = new Set([...avatarErrors, did]); 15 + if (!hasAttemptedRefresh(did)) triggerAvatarRefresh(did); 16 + } 17 + 18 + function shouldShowFallback(liker: LikerData): boolean { 19 + const url = getDisplayUrl(liker); 20 + return !url || avatarErrors.has(liker.did); 21 + } 22 + 23 + function formatTime(isoString: string): string { 24 + const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000); 25 + if (seconds < 60) return 'just now'; 26 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 27 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 28 + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 29 + return `${Math.floor(seconds / 604800)}w ago`; 30 + } 31 + 32 + function handleBackdropClick(event: MouseEvent) { 33 + if (event.target === event.currentTarget) likersSheet.close(); 34 + } 35 + </script> 36 + 37 + <div 38 + class="sheet-backdrop" 39 + class:open={likersSheet.isOpen} 40 + role="presentation" 41 + onclick={handleBackdropClick} 42 + > 43 + <div class="sheet" role="dialog" aria-modal="true" aria-label="liked by"> 44 + <div class="sheet-handle"></div> 45 + <div class="sheet-header"> 46 + <span class="sheet-title"> 47 + {likersSheet.likeCount} {likersSheet.likeCount === 1 ? 'like' : 'likes'} 48 + </span> 49 + <button class="sheet-close" onclick={() => likersSheet.close()} aria-label="close"> 50 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> 51 + <line x1="18" y1="6" x2="6" y2="18"></line> 52 + <line x1="6" y1="6" x2="18" y2="18"></line> 53 + </svg> 54 + </button> 55 + </div> 56 + <div class="sheet-content"> 57 + {#if likersSheet.loading} 58 + <div class="sheet-loading"> 59 + {#each [1, 2, 3] as _, i (i)} 60 + <div class="liker-skeleton"> 61 + <div class="avatar-skeleton"></div> 62 + <div class="text-skeleton"></div> 63 + </div> 64 + {/each} 65 + </div> 66 + {:else if likersSheet.error} 67 + <div class="sheet-empty">{likersSheet.error}</div> 68 + {:else if likersSheet.likers.length > 0} 69 + <div class="likers-list"> 70 + {#each likersSheet.likers as liker (liker.did)} 71 + {@const displayUrl = getDisplayUrl(liker)} 72 + {@const showFallback = shouldShowFallback(liker)} 73 + <a href="/u/{liker.handle}/liked" class="liker-row" onclick={() => likersSheet.close()}> 74 + <div class="liker-avatar"> 75 + {#if displayUrl && !showFallback} 76 + <SensitiveImage src={displayUrl} compact> 77 + <img 78 + src={displayUrl} 79 + alt="" 80 + onerror={() => handleAvatarError(liker.did)} 81 + /> 82 + </SensitiveImage> 83 + {:else} 84 + <span class="liker-initial">{(liker.display_name || liker.handle).charAt(0).toUpperCase()}</span> 85 + {/if} 86 + </div> 87 + <div class="liker-info"> 88 + <span class="liker-name">{liker.display_name || liker.handle}</span> 89 + <span class="liker-time">{formatTime(liker.liked_at)}</span> 90 + </div> 91 + </a> 92 + {/each} 93 + </div> 94 + {:else} 95 + <div class="sheet-empty">be the first to like this</div> 96 + {/if} 97 + </div> 98 + </div> 99 + </div> 100 + 101 + <style> 102 + .sheet-backdrop { 103 + position: fixed; 104 + inset: 0; 105 + background: color-mix(in srgb, var(--bg-primary) 60%, transparent); 106 + backdrop-filter: blur(4px); 107 + -webkit-backdrop-filter: blur(4px); 108 + z-index: 9999; 109 + opacity: 0; 110 + pointer-events: none; 111 + transition: opacity 0.15s; 112 + display: flex; 113 + align-items: flex-end; 114 + justify-content: center; 115 + } 116 + 117 + .sheet-backdrop.open { 118 + opacity: 1; 119 + pointer-events: auto; 120 + } 121 + 122 + .sheet { 123 + width: 100%; 124 + max-width: 400px; 125 + max-height: 60vh; 126 + background: var(--bg-secondary); 127 + border: 1px solid var(--border-subtle); 128 + border-bottom: none; 129 + border-radius: var(--radius-xl) var(--radius-xl) 0 0; 130 + display: flex; 131 + flex-direction: column; 132 + transform: translateY(100%); 133 + transition: transform 0.2s ease-out; 134 + padding-bottom: env(safe-area-inset-bottom, 0px); 135 + } 136 + 137 + .sheet-backdrop.open .sheet { 138 + transform: translateY(0); 139 + } 140 + 141 + .sheet-handle { 142 + width: 32px; 143 + height: 4px; 144 + background: var(--border-default); 145 + border-radius: 2px; 146 + margin: 0.75rem auto 0; 147 + flex-shrink: 0; 148 + } 149 + 150 + .sheet-header { 151 + display: flex; 152 + align-items: center; 153 + justify-content: space-between; 154 + padding: 0.75rem 1rem; 155 + flex-shrink: 0; 156 + } 157 + 158 + .sheet-title { 159 + font-size: var(--text-base); 160 + font-weight: 600; 161 + color: var(--text-primary); 162 + } 163 + 164 + .sheet-close { 165 + background: none; 166 + border: none; 167 + color: var(--text-muted); 168 + cursor: pointer; 169 + padding: 0.25rem; 170 + border-radius: var(--radius-sm); 171 + transition: color 0.15s; 172 + display: flex; 173 + align-items: center; 174 + justify-content: center; 175 + } 176 + 177 + .sheet-close:hover { 178 + color: var(--text-primary); 179 + } 180 + 181 + .sheet-content { 182 + overflow-y: auto; 183 + padding: 0 1rem 1rem; 184 + flex: 1; 185 + min-height: 0; 186 + } 187 + 188 + .likers-list { 189 + display: flex; 190 + flex-direction: column; 191 + } 192 + 193 + .liker-row { 194 + display: flex; 195 + align-items: center; 196 + gap: 0.75rem; 197 + padding: 0.625rem 0; 198 + text-decoration: none; 199 + color: inherit; 200 + border-bottom: 1px solid var(--border-subtle); 201 + transition: background 0.15s; 202 + border-radius: var(--radius-sm); 203 + padding-left: 0.25rem; 204 + padding-right: 0.25rem; 205 + } 206 + 207 + .liker-row:last-child { 208 + border-bottom: none; 209 + } 210 + 211 + .liker-row:active { 212 + background: var(--bg-tertiary); 213 + } 214 + 215 + .liker-avatar { 216 + width: 40px; 217 + height: 40px; 218 + border-radius: var(--radius-full); 219 + overflow: hidden; 220 + background: var(--bg-tertiary); 221 + flex-shrink: 0; 222 + display: flex; 223 + align-items: center; 224 + justify-content: center; 225 + } 226 + 227 + .liker-avatar img { 228 + width: 100%; 229 + height: 100%; 230 + object-fit: cover; 231 + } 232 + 233 + .liker-initial { 234 + font-size: var(--text-sm); 235 + font-weight: 600; 236 + color: var(--text-secondary); 237 + } 238 + 239 + .liker-info { 240 + display: flex; 241 + flex-direction: column; 242 + gap: 0.125rem; 243 + min-width: 0; 244 + } 245 + 246 + .liker-name { 247 + font-size: var(--text-sm); 248 + font-weight: 500; 249 + color: var(--text-primary); 250 + white-space: nowrap; 251 + overflow: hidden; 252 + text-overflow: ellipsis; 253 + } 254 + 255 + .liker-time { 256 + font-size: var(--text-xs); 257 + color: var(--text-tertiary); 258 + } 259 + 260 + .sheet-empty { 261 + color: var(--text-tertiary); 262 + font-size: var(--text-sm); 263 + text-align: center; 264 + padding: 2rem 1rem; 265 + } 266 + 267 + .sheet-loading { 268 + display: flex; 269 + flex-direction: column; 270 + } 271 + 272 + .liker-skeleton { 273 + display: flex; 274 + align-items: center; 275 + gap: 0.75rem; 276 + padding: 0.625rem 0.25rem; 277 + } 278 + 279 + .avatar-skeleton { 280 + width: 40px; 281 + height: 40px; 282 + border-radius: var(--radius-full); 283 + background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 284 + background-size: 200% 100%; 285 + animation: shimmer 1.5s ease-in-out infinite; 286 + flex-shrink: 0; 287 + } 288 + 289 + .text-skeleton { 290 + width: 120px; 291 + height: 14px; 292 + border-radius: var(--radius-sm); 293 + background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 294 + background-size: 200% 100%; 295 + animation: shimmer 1.5s ease-in-out infinite; 296 + } 297 + 298 + @keyframes shimmer { 299 + 0% { background-position: 200% 0; } 300 + 100% { background-position: -200% 0; } 301 + } 302 + 303 + @media (prefers-reduced-motion: reduce) { 304 + .avatar-skeleton, 305 + .text-skeleton { 306 + animation: none; 307 + } 308 + .sheet { 309 + transition: none; 310 + } 311 + .sheet-backdrop { 312 + transition: none; 313 + } 314 + } 315 + </style>
-215
frontend/src/lib/components/LikersStrip.svelte
··· 1 - <script lang="ts"> 2 - import { API_URL } from '$lib/config'; 3 - import { getLikers, setLikers, type LikerData } from '$lib/tooltip-cache.svelte'; 4 - import type { UserPreview } from '$lib/types'; 5 - import AvatarStack from './AvatarStack.svelte'; 6 - 7 - interface Props { 8 - trackId: number; 9 - /** total like count (for the "+N" overflow label). */ 10 - likeCount: number; 11 - /** preview likers embedded in the track response — up to 3. */ 12 - topLikers: UserPreview[]; 13 - /** avatar diameter. default 22. */ 14 - size?: number; 15 - /** border color matching the surrounding background. */ 16 - borderColor?: string; 17 - /** max width of the horizontal scroll container when expanded. */ 18 - maxScrollWidth?: string; 19 - } 20 - 21 - let { 22 - trackId, 23 - likeCount, 24 - topLikers, 25 - size = 22, 26 - borderColor = 'var(--bg-secondary)', 27 - maxScrollWidth = '20rem' 28 - }: Props = $props(); 29 - 30 - // expansion state: managed locally so each strip on a page is independent. 31 - let expanded = $state(false); 32 - let allLikers = $state<LikerData[] | null>(null); 33 - let loading = $state(false); 34 - let container: HTMLSpanElement | null = $state(null); 35 - 36 - // once the full list has been loaded, show everything — otherwise show the 37 - // 3 previewed likers the backend sent inline with the track response. 38 - let usersForStack = $derived<UserPreview[]>( 39 - expanded && allLikers ? allLikers : topLikers 40 - ); 41 - 42 - async function fetchAllLikers(): Promise<LikerData[]> { 43 - const cached = getLikers(trackId); 44 - if (cached) return cached; 45 - const response = await fetch(`${API_URL}/tracks/${trackId}/likes`); 46 - if (!response.ok) throw new Error(`failed to fetch likers: ${response.status}`); 47 - const data = await response.json(); 48 - const users: LikerData[] = data.users ?? []; 49 - setLikers(trackId, users); 50 - return users; 51 - } 52 - 53 - async function handleMoreClick(e: MouseEvent | KeyboardEvent) { 54 - // stop the click on +N from bubbling to an enclosing play button, 55 - // but only on this non-anchor element — anchor clicks (individual 56 - // avatars) must still reach document for SvelteKit's client-side 57 - // nav to hijack them, otherwise the browser does a full page reload 58 - // and tears down the audio element mid-playback. 59 - e.stopPropagation(); 60 - if (loading) return; 61 - if (allLikers) { 62 - // already loaded — just toggle. second click collapses. 63 - expanded = !expanded; 64 - return; 65 - } 66 - loading = true; 67 - try { 68 - allLikers = await fetchAllLikers(); 69 - expanded = true; 70 - } catch (err) { 71 - console.error('error expanding likers:', err); 72 - } finally { 73 - loading = false; 74 - } 75 - } 76 - 77 - // click-outside to collapse: the expanded horizontal scroll is transient. 78 - // click-to-expand, click-again (on +N) or click-outside to collapse. 79 - function handleDocumentClick(e: MouseEvent) { 80 - if (!expanded || !container) return; 81 - if (e.target instanceof Node && !container.contains(e.target)) { 82 - expanded = false; 83 - } 84 - } 85 - 86 - function handleDocumentKeydown(e: KeyboardEvent) { 87 - if (expanded && e.key === 'Escape') { 88 - expanded = false; 89 - } 90 - } 91 - 92 - $effect(() => { 93 - if (!expanded) return; 94 - document.addEventListener('click', handleDocumentClick, true); 95 - document.addEventListener('keydown', handleDocumentKeydown); 96 - return () => { 97 - document.removeEventListener('click', handleDocumentClick, true); 98 - document.removeEventListener('keydown', handleDocumentKeydown); 99 - }; 100 - }); 101 - 102 - let likeWord = $derived(likeCount === 1 ? 'like' : 'likes'); 103 - 104 - function formatRelativeTime(iso: string): string { 105 - const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); 106 - if (seconds < 60) return 'just now'; 107 - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 108 - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 109 - if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 110 - return `${Math.floor(seconds / 604800)}w ago`; 111 - } 112 - 113 - function avatarTitle(u: UserPreview): string { 114 - const name = u.display_name || u.handle; 115 - return u.liked_at ? `${name} · liked ${formatRelativeTime(u.liked_at)}` : name; 116 - } 117 - </script> 118 - 119 - <!-- NOTE: we deliberately do NOT stopPropagation at this root. avatar clicks 120 - are anchor links and must reach document so SvelteKit's client-side nav 121 - can hijack them — otherwise the browser falls back to a full page 122 - reload which tears down the audio element and stops playback. 123 - The outer play button in TrackItem already has an anchor guard that 124 - prevents playback when the click target is (or is inside) an <a>. 125 - The non-anchor interactive bits (+N, ×) stop propagation individually. --> 126 - <span 127 - class="likers-strip" 128 - class:expanded 129 - class:loading 130 - bind:this={container} 131 - aria-live="polite" 132 - > 133 - <span class="label">liked by</span> 134 - <AvatarStack 135 - users={usersForStack} 136 - total={likeCount} 137 - maxVisible={expanded ? likeCount : 3} 138 - {size} 139 - {borderColor} 140 - {maxScrollWidth} 141 - scrollable={expanded} 142 - onMoreClick={expanded ? undefined : handleMoreClick} 143 - avatarHref={(u) => `/u/${u.handle}`} 144 - {avatarTitle} 145 - ariaLabel={`${likeCount} ${likeWord}`} 146 - /> 147 - {#if expanded} 148 - <button 149 - class="collapse" 150 - type="button" 151 - onclick={(e) => { 152 - e.stopPropagation(); 153 - expanded = false; 154 - }} 155 - title="collapse" 156 - aria-label="collapse likers" 157 - style="--collapse-size: {size}px;" 158 - > 159 - <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"> 160 - <line x1="6" y1="6" x2="14" y2="14"></line> 161 - <line x1="14" y1="6" x2="6" y2="14"></line> 162 - </svg> 163 - </button> 164 - {/if} 165 - </span> 166 - 167 - <style> 168 - .likers-strip { 169 - display: inline-flex; 170 - align-items: center; 171 - gap: 0.375rem; 172 - vertical-align: middle; 173 - transition: opacity 0.15s; 174 - } 175 - 176 - .likers-strip.loading { 177 - opacity: 0.6; 178 - } 179 - 180 - .label { 181 - color: var(--text-tertiary); 182 - font-size: inherit; 183 - font-family: inherit; 184 - white-space: nowrap; 185 - } 186 - 187 - .collapse { 188 - width: var(--collapse-size); 189 - height: var(--collapse-size); 190 - display: inline-flex; 191 - align-items: center; 192 - justify-content: center; 193 - padding: 0; 194 - margin-left: 0.25rem; 195 - background: transparent; 196 - border: 1px solid var(--border-subtle); 197 - border-radius: var(--radius-full); 198 - color: var(--text-tertiary); 199 - cursor: pointer; 200 - transition: color 0.15s, border-color 0.15s, transform 0.15s; 201 - flex-shrink: 0; 202 - } 203 - 204 - .collapse:hover, 205 - .collapse:focus-visible { 206 - color: var(--accent); 207 - border-color: var(--accent); 208 - transform: scale(1.08); 209 - } 210 - 211 - .collapse svg { 212 - width: 55%; 213 - height: 55%; 214 - } 215 - </style>
+328
frontend/src/lib/components/LikersTooltip.svelte
··· 1 + <script lang="ts"> 2 + import { API_URL } from '$lib/config'; 3 + import { getLikers, setLikers, type LikerData } from '$lib/tooltip-cache.svelte'; 4 + import { 5 + getRefreshedAvatar, 6 + triggerAvatarRefresh, 7 + hasAttemptedRefresh 8 + } from '$lib/avatar-refresh.svelte'; 9 + import { fade } from 'svelte/transition'; 10 + import SensitiveImage from './SensitiveImage.svelte'; 11 + 12 + interface Props { 13 + trackId: number; 14 + likeCount: number; 15 + onMouseEnter?: () => void; 16 + onMouseLeave?: () => void; 17 + forceBelow?: boolean; 18 + } 19 + 20 + let { trackId, likeCount, onMouseEnter, onMouseLeave, forceBelow = false }: Props = $props(); 21 + 22 + let likers = $state<LikerData[]>([]); 23 + let loading = $state(true); 24 + let error = $state<string | null>(null); 25 + let tooltipElement: HTMLDivElement | null = $state(null); 26 + let positionBelow = $state(false); 27 + 28 + // fixed positioning for tooltips inside overflow containers 29 + let fixedTop = $state(0); 30 + let fixedLeft = $state(0); 31 + 32 + // track which avatars have errored (by DID) 33 + let avatarErrors = $state<Set<string>>(new Set()); 34 + 35 + /** 36 + * get the display URL for a liker's avatar. 37 + * prefers refreshed URL from global cache, falls back to original. 38 + */ 39 + function getDisplayUrl(liker: LikerData): string | null { 40 + const refreshed = getRefreshedAvatar(liker.did); 41 + return refreshed ?? liker.avatar_url; 42 + } 43 + 44 + /** 45 + * handle avatar load error - show fallback and trigger refresh. 46 + */ 47 + function handleAvatarError(did: string) { 48 + avatarErrors = new Set([...avatarErrors, did]); 49 + 50 + if (!hasAttemptedRefresh(did)) { 51 + triggerAvatarRefresh(did); 52 + } 53 + } 54 + 55 + /** 56 + * check if avatar should show fallback. 57 + */ 58 + function shouldShowFallback(liker: LikerData): boolean { 59 + const url = getDisplayUrl(liker); 60 + return !url || avatarErrors.has(liker.did); 61 + } 62 + 63 + // position tooltip — use fixed positioning when forceBelow to escape overflow containers 64 + $effect(() => { 65 + if (forceBelow) { 66 + positionBelow = true; 67 + if (!tooltipElement) return; 68 + const parent = tooltipElement.parentElement; 69 + if (!parent) return; 70 + const rect = parent.getBoundingClientRect(); 71 + fixedTop = rect.bottom + 8; 72 + fixedLeft = rect.left + rect.width / 2; 73 + return; 74 + } 75 + 76 + if (!tooltipElement) return; 77 + 78 + const parent = tooltipElement.parentElement; 79 + if (!parent) return; 80 + 81 + const parentRect = parent.getBoundingClientRect(); 82 + positionBelow = parentRect.top < 200; 83 + }); 84 + 85 + $effect(() => { 86 + if (likeCount === 0) { 87 + loading = false; 88 + return; 89 + } 90 + 91 + // check cache first 92 + const cached = getLikers(trackId); 93 + if (cached) { 94 + likers = cached; 95 + loading = false; 96 + return; 97 + } 98 + 99 + const fetchLikers = async () => { 100 + try { 101 + const url = `${API_URL}/tracks/${trackId}/likes`; 102 + const response = await fetch(url); 103 + 104 + if (!response.ok) { 105 + throw new Error(`failed to fetch likers: ${response.status}`); 106 + } 107 + 108 + const data = await response.json(); 109 + const users = data.users || []; 110 + likers = users; 111 + setLikers(trackId, users); 112 + } catch (err) { 113 + error = 'failed to load'; 114 + console.error('error fetching likers:', err); 115 + } finally { 116 + loading = false; 117 + } 118 + }; 119 + 120 + fetchLikers(); 121 + }); 122 + 123 + function formatTime(isoString: string): string { 124 + const date = new Date(isoString); 125 + const now = new Date(); 126 + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); 127 + 128 + if (seconds < 60) return 'just now'; 129 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 130 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 131 + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 132 + return `${Math.floor(seconds / 604800)}w ago`; 133 + } 134 + </script> 135 + 136 + <div 137 + bind:this={tooltipElement} 138 + class="likers-tooltip" 139 + class:position-below={positionBelow} 140 + class:position-fixed={forceBelow} 141 + style={forceBelow ? `top: ${fixedTop}px; left: ${fixedLeft}px;` : ''} 142 + role="tooltip" 143 + onmouseenter={onMouseEnter} 144 + onmouseleave={onMouseLeave} 145 + > 146 + {#key loading} 147 + {#if loading} 148 + <div class="loading" transition:fade={{ duration: 200 }}> 149 + <div class="loading-avatars"> 150 + {#each [1, 2, 3] as _} 151 + <div class="avatar-skeleton"></div> 152 + {/each} 153 + </div> 154 + </div> 155 + {:else if error} 156 + <div class="error" transition:fade={{ duration: 200 }}>{error}</div> 157 + {:else if likers.length > 0} 158 + <div class="likers-avatars" transition:fade={{ duration: 200 }}> 159 + {#each likers as liker (liker.did)} 160 + {@const displayUrl = getDisplayUrl(liker)} 161 + {@const showFallback = shouldShowFallback(liker)} 162 + <a 163 + href="/u/{liker.handle}/liked" 164 + class="liker-circle" 165 + title="{liker.display_name} (@{liker.handle}) • {formatTime(liker.liked_at)}" 166 + > 167 + {#if displayUrl && !showFallback} 168 + <SensitiveImage src={displayUrl} compact> 169 + <img 170 + src={displayUrl} 171 + alt="" 172 + onerror={() => handleAvatarError(liker.did)} 173 + /> 174 + </SensitiveImage> 175 + {:else} 176 + <span>{(liker.display_name || liker.handle).charAt(0).toUpperCase()}</span> 177 + {/if} 178 + </a> 179 + {/each} 180 + </div> 181 + {:else} 182 + <div class="empty" transition:fade={{ duration: 200 }}>be the first to like this</div> 183 + {/if} 184 + {/key} 185 + </div> 186 + 187 + <style> 188 + .likers-tooltip { 189 + position: absolute; 190 + bottom: 100%; 191 + left: 50%; 192 + transform: translateX(-50%); 193 + margin-bottom: 0.625rem; 194 + background: var(--bg-secondary); 195 + border: 1px solid var(--border-subtle); 196 + border-radius: var(--radius-lg); 197 + padding: 0.5rem 0.625rem; 198 + box-shadow: 199 + 0 4px 16px rgba(0, 0, 0, 0.4), 200 + 0 0 0 1px rgba(255, 255, 255, 0.03); 201 + z-index: 1000; 202 + pointer-events: auto; 203 + } 204 + 205 + .likers-tooltip.position-below { 206 + bottom: auto; 207 + top: 100%; 208 + margin-bottom: 0; 209 + margin-top: 0.625rem; 210 + } 211 + 212 + .likers-tooltip.position-fixed { 213 + position: fixed; 214 + bottom: auto; 215 + top: auto; 216 + left: auto; 217 + margin: 0; 218 + } 219 + 220 + .loading, 221 + .error, 222 + .empty { 223 + color: var(--text-tertiary); 224 + font-size: var(--text-sm); 225 + text-align: center; 226 + padding: 0.25rem 0.5rem; 227 + white-space: nowrap; 228 + } 229 + 230 + .error { 231 + color: var(--error); 232 + } 233 + 234 + .loading-avatars { 235 + display: flex; 236 + justify-content: center; 237 + } 238 + 239 + .avatar-skeleton { 240 + width: 32px; 241 + height: 32px; 242 + border-radius: var(--radius-full); 243 + background: linear-gradient( 244 + 90deg, 245 + var(--bg-tertiary) 0%, 246 + var(--bg-hover) 50%, 247 + var(--bg-tertiary) 100% 248 + ); 249 + background-size: 200% 100%; 250 + animation: shimmer 1.5s ease-in-out infinite; 251 + border: 2px solid var(--bg-secondary); 252 + margin-left: -8px; 253 + flex-shrink: 0; 254 + } 255 + 256 + .avatar-skeleton:first-child { 257 + margin-left: 0; 258 + } 259 + 260 + @keyframes shimmer { 261 + 0% { background-position: 200% 0; } 262 + 100% { background-position: -200% 0; } 263 + } 264 + 265 + .likers-avatars { 266 + display: flex; 267 + justify-content: flex-start; 268 + overflow-x: auto; 269 + max-width: 240px; 270 + width: fit-content; 271 + margin: 0 auto; 272 + padding: 0.25rem 0; 273 + scrollbar-width: none; 274 + } 275 + 276 + .likers-avatars::-webkit-scrollbar { 277 + display: none; 278 + } 279 + 280 + .liker-circle { 281 + width: 32px; 282 + height: 32px; 283 + border-radius: var(--radius-full); 284 + border: 2px solid var(--bg-secondary); 285 + background: var(--bg-tertiary); 286 + display: flex; 287 + align-items: center; 288 + justify-content: center; 289 + overflow: hidden; 290 + margin-left: -8px; 291 + transition: 292 + transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), 293 + z-index 0s; 294 + position: relative; 295 + text-decoration: none; 296 + flex-shrink: 0; 297 + } 298 + 299 + .liker-circle:first-child { 300 + margin-left: 0; 301 + } 302 + 303 + .liker-circle:hover { 304 + transform: translateY(-2px) scale(1.08); 305 + z-index: 10; 306 + } 307 + 308 + .liker-circle img { 309 + width: 100%; 310 + height: 100%; 311 + object-fit: cover; 312 + } 313 + 314 + .liker-circle span { 315 + font-size: var(--text-xs); 316 + font-weight: 600; 317 + color: var(--text-secondary); 318 + } 319 + 320 + @media (prefers-reduced-motion: reduce) { 321 + .avatar-skeleton { 322 + animation: none; 323 + } 324 + .liker-circle { 325 + transition: none; 326 + } 327 + } 328 + </style>
+99 -13
frontend/src/lib/components/TrackCard.svelte
··· 1 1 <script lang="ts"> 2 + import { browser } from '$app/environment'; 2 3 import SensitiveImage from './SensitiveImage.svelte'; 3 - import LikersStrip from './LikersStrip.svelte'; 4 + import LikersTooltip from './LikersTooltip.svelte'; 5 + import { likersSheet } from '$lib/likers-sheet.svelte'; 4 6 import type { Track } from '$lib/types'; 5 7 6 8 interface Props { ··· 14 16 15 17 let imageLoading = $derived(index < 3 ? 'eager' as const : 'lazy' as const); 16 18 let likeCount = $derived(track.like_count || 0); 17 - let topLikers = $derived(track.top_likers ?? []); 19 + 20 + let isMobile = $state(false); 21 + 22 + $effect(() => { 23 + if (browser) { 24 + const mq = window.matchMedia('(max-width: 768px)'); 25 + isMobile = mq.matches; 26 + const handler = (e: MediaQueryListEvent) => (isMobile = e.matches); 27 + mq.addEventListener('change', handler); 28 + return () => mq.removeEventListener('change', handler); 29 + } 30 + }); 31 + 32 + // desktop tooltip state 33 + let showLikersTooltip = $state(false); 34 + let likersTooltipTimeout: ReturnType<typeof setTimeout> | null = null; 35 + 36 + function openLikers() { 37 + if (likersTooltipTimeout) { 38 + clearTimeout(likersTooltipTimeout); 39 + likersTooltipTimeout = null; 40 + } 41 + showLikersTooltip = true; 42 + } 43 + 44 + function closeLikers() { 45 + likersTooltipTimeout = setTimeout(() => { 46 + showLikersTooltip = false; 47 + likersTooltipTimeout = null; 48 + }, 150); 49 + } 50 + 51 + function handleLikesClick(e: Event) { 52 + e.stopPropagation(); 53 + if (isMobile) { 54 + likersSheet.open(track.id, likeCount); 55 + } 56 + } 57 + 58 + function handleLikesKeydown(e: KeyboardEvent) { 59 + if (e.key === 'Enter' || e.key === ' ') { 60 + e.stopPropagation(); 61 + if (isMobile) { 62 + likersSheet.open(track.id, likeCount); 63 + } 64 + } 65 + } 66 + 67 + function handleLikesMouseEnter(e: Event) { 68 + if (isMobile) return; 69 + e.stopPropagation(); 70 + openLikers(); 71 + } 72 + 73 + function handleLikesMouseLeave(e: Event) { 74 + if (isMobile) return; 75 + e.stopPropagation(); 76 + closeLikers(); 77 + } 18 78 </script> 19 79 20 80 <button 21 81 class="track-card" 22 82 class:playing={isPlaying} 83 + class:tooltip-open={showLikersTooltip} 23 84 onclick={(e) => { 24 85 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) return; 25 86 onPlay(track); ··· 71 132 <span>{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span> 72 133 {#if likeCount > 0} 73 134 <span class="meta-sep">&middot;</span> 74 - <span class="likes" onclick={(e) => e.stopPropagation()} role="presentation"> 75 - {#if topLikers.length > 0} 76 - <LikersStrip 135 + <span 136 + class="likes" 137 + role="button" 138 + tabindex="0" 139 + aria-label="{likeCount} {likeCount === 1 ? 'like' : 'likes'}" 140 + aria-expanded={showLikersTooltip} 141 + onclick={handleLikesClick} 142 + onkeydown={handleLikesKeydown} 143 + onmouseenter={handleLikesMouseEnter} 144 + onmouseleave={handleLikesMouseLeave} 145 + onfocus={handleLikesMouseEnter} 146 + onblur={handleLikesMouseLeave} 147 + > 148 + {likeCount} {likeCount === 1 ? 'like' : 'likes'} 149 + {#if showLikersTooltip && !isMobile} 150 + <LikersTooltip 77 151 trackId={track.id} 78 152 {likeCount} 79 - {topLikers} 80 - size={18} 81 - borderColor="var(--track-bg, var(--bg-secondary))" 82 - maxScrollWidth="16rem" 153 + onMouseEnter={openLikers} 154 + onMouseLeave={closeLikers} 155 + forceBelow 83 156 /> 84 - {:else} 85 - {likeCount} {likeCount === 1 ? 'like' : 'likes'} 86 157 {/if} 87 158 </span> 88 159 {/if} ··· 121 192 .track-card.playing { 122 193 background: color-mix(in srgb, var(--accent) 10%, var(--track-bg-playing, var(--bg-tertiary))); 123 194 border-color: color-mix(in srgb, var(--accent) 20%, var(--track-border, var(--border-subtle))); 195 + } 196 + 197 + .track-card.tooltip-open { 198 + z-index: 60; 124 199 } 125 200 126 201 .artwork { ··· 237 312 } 238 313 239 314 .likes { 240 - display: inline-flex; 241 - align-items: center; 315 + position: relative; 316 + cursor: help; 317 + transition: color 0.15s; 318 + } 319 + 320 + .likes:hover { 321 + color: var(--accent); 322 + } 323 + 324 + @media (max-width: 768px) { 325 + .likes { 326 + cursor: pointer; 327 + } 242 328 } 243 329 </style>
+85 -18
frontend/src/lib/components/TrackItem.svelte
··· 3 3 import ShareButton from './ShareButton.svelte'; 4 4 import AddToMenu from './AddToMenu.svelte'; 5 5 import TrackActionsMenu from './TrackActionsMenu.svelte'; 6 - import LikersStrip from './LikersStrip.svelte'; 6 + import LikersTooltip from './LikersTooltip.svelte'; 7 7 import CommentersTooltip from './CommentersTooltip.svelte'; 8 8 import SensitiveImage from './SensitiveImage.svelte'; 9 9 import { hasPlayableLossless, isLosslessFormat } from '$lib/audio-support'; 10 + import { likersSheet } from '$lib/likers-sheet.svelte'; 10 11 import type { Track } from '$lib/types'; 11 12 import { queue } from '$lib/queue.svelte'; 12 13 import { toast } from '$lib/toast.svelte'; ··· 57 58 } 58 59 }); 59 60 61 + let showLikersTooltip = $state(false); 60 62 let showCommentersTooltip = $state(false); 61 63 // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 62 64 let likeCount = $derived(track.like_count || 0); 63 - let topLikers = $derived(track.top_likers ?? []); 64 65 let commentCount = $derived(track.comment_count || 0); 65 66 // local UI state keyed by track.id - reset when track changes (component recycling) 66 67 let trackImageError = $state(false); ··· 116 117 toast.success(`queued ${track.title}`, 1800); 117 118 } 118 119 120 + let likersTooltipTimeout: ReturnType<typeof setTimeout> | null = null; 121 + 122 + function handleLikesMouseEnter() { 123 + if (isMobile) return; 124 + if (likersTooltipTimeout) { 125 + clearTimeout(likersTooltipTimeout); 126 + likersTooltipTimeout = null; 127 + } 128 + showLikersTooltip = true; 129 + } 130 + 131 + function handleLikesMouseLeave() { 132 + if (isMobile) return; 133 + likersTooltipTimeout = setTimeout(() => { 134 + showLikersTooltip = false; 135 + likersTooltipTimeout = null; 136 + }, 150); 137 + } 138 + 139 + function handleLikesClick(e: Event) { 140 + if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) { 141 + return; 142 + } 143 + e.stopPropagation(); 144 + if (isMobile) { 145 + likersSheet.open(track.id, likeCount); 146 + } 147 + } 148 + 149 + function handleLikesKeydown(event: KeyboardEvent) { 150 + if (event.key === 'Enter' || event.key === ' ') { 151 + event.preventDefault(); 152 + if (isMobile) { 153 + likersSheet.open(track.id, likeCount); 154 + } else { 155 + showLikersTooltip = true; 156 + } 157 + } 158 + if (event.key === 'Escape') { 159 + showLikersTooltip = false; 160 + } 161 + } 162 + 119 163 let commentersTooltipTimeout: ReturnType<typeof setTimeout> | null = null; 120 164 121 165 function handleCommentsMouseEnter() { ··· 154 198 class="track-container" 155 199 class:playing={isPlaying} 156 200 class:lossless={hasPlayableLossless(track.original_file_type) || isLosslessFormat(track.file_type)} 201 + class:likers-tooltip-open={showLikersTooltip} 157 202 title={hasPlayableLossless(track.original_file_type) || isLosslessFormat(track.file_type) ? 'lossless audio available' : undefined} 158 203 > 159 204 {#if showIndex} ··· 285 330 <span class="plays">{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span> 286 331 {#if likeCount > 0} 287 332 <span class="meta-separator">•</span> 288 - <span class="likes"> 289 - {#if topLikers.length > 0} 290 - <LikersStrip 291 - trackId={track.id} 292 - {likeCount} 293 - {topLikers} 294 - size={isMobile ? 20 : 22} 295 - borderColor="var(--track-bg, var(--bg-secondary))" 296 - /> 297 - {:else} 298 - {likeCount} {likeCount === 1 ? 'like' : 'likes'} 299 - {/if} 300 - </span> 301 - {/if} 333 + <span 334 + class="likes" 335 + role="button" 336 + tabindex="0" 337 + aria-label={`${likeCount} ${likeCount === 1 ? 'like' : 'likes'} (focus to view users)`} 338 + aria-expanded={showLikersTooltip} 339 + onclick={handleLikesClick} 340 + onmouseenter={handleLikesMouseEnter} 341 + onmouseleave={handleLikesMouseLeave} 342 + onfocus={handleLikesMouseEnter} 343 + onblur={handleLikesMouseLeave} 344 + onkeydown={handleLikesKeydown} 345 + > 346 + {likeCount} {likeCount === 1 ? 'like' : 'likes'} 347 + {#if showLikersTooltip && !isMobile} 348 + <LikersTooltip 349 + trackId={track.id} 350 + likeCount={likeCount} 351 + onMouseEnter={handleLikesMouseEnter} 352 + onMouseLeave={handleLikesMouseLeave} 353 + /> 354 + {/if} 355 + </span> 356 + {/if} 302 357 {#if commentCount > 0} 303 358 <span class="meta-separator">•</span> 304 359 <span ··· 453 508 0 1px 3px rgba(0, 0, 0, 0.06), 454 509 0 0 8px color-mix(in srgb, var(--accent) 8%, transparent), 455 510 inset 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); 511 + } 512 + 513 + /* elevate entire track container when likers tooltip is open 514 + z-index: 60 is above header (50) and sibling tracks */ 515 + .track-container.likers-tooltip-open { 516 + position: relative; 517 + z-index: 60; 456 518 } 457 519 458 520 .track { ··· 779 841 .likes { 780 842 color: var(--text-tertiary); 781 843 font-family: inherit; 782 - display: inline-flex; 783 - align-items: center; 844 + position: relative; 845 + cursor: help; 846 + transition: color 0.2s; 847 + } 848 + 849 + .likes:hover { 850 + color: var(--accent); 784 851 } 785 852 786 853 .comments-wrapper {
+55
frontend/src/lib/likers-sheet.svelte.ts
··· 1 + import { API_URL } from '$lib/config'; 2 + import { getLikers, setLikers, type LikerData } from '$lib/tooltip-cache.svelte'; 3 + 4 + class LikersSheetState { 5 + isOpen = $state(false); 6 + trackId = $state<number | null>(null); 7 + likeCount = $state(0); 8 + likers = $state<LikerData[]>([]); 9 + loading = $state(false); 10 + error = $state<string | null>(null); 11 + 12 + open(trackId: number, likeCount: number) { 13 + this.trackId = trackId; 14 + this.likeCount = likeCount; 15 + this.isOpen = true; 16 + this.error = null; 17 + 18 + const cached = getLikers(trackId); 19 + if (cached) { 20 + this.likers = cached; 21 + this.loading = false; 22 + return; 23 + } 24 + 25 + this.likers = []; 26 + this.loading = true; 27 + this.fetchLikers(trackId); 28 + } 29 + 30 + close() { 31 + this.isOpen = false; 32 + } 33 + 34 + private async fetchLikers(trackId: number) { 35 + try { 36 + const response = await fetch(`${API_URL}/tracks/${trackId}/likes`); 37 + if (!response.ok) throw new Error(`failed to fetch likers: ${response.status}`); 38 + const data = await response.json(); 39 + const users: LikerData[] = data.users || []; 40 + 41 + // stale guard — sheet may have been closed/reopened for a different track 42 + if (this.trackId !== trackId) return; 43 + 44 + this.likers = users; 45 + setLikers(trackId, users); 46 + } catch { 47 + if (this.trackId !== trackId) return; 48 + this.error = 'failed to load'; 49 + } finally { 50 + if (this.trackId === trackId) this.loading = false; 51 + } 52 + } 53 + } 54 + 55 + export const likersSheet = new LikersSheetState();
-16
frontend/src/lib/types.ts
··· 32 32 type: 'any' | string; 33 33 } 34 34 35 - /** 36 - * Lightweight user preview used in overlapping avatar stacks (track like 37 - * strips, supporter rows, etc). Matches backend `LikerPreview` and the 38 - * atprotofans-sourced `Supporter` shape. 39 - */ 40 - export interface UserPreview { 41 - did: string; 42 - handle: string; 43 - display_name?: string | null; 44 - avatar_url?: string | null; 45 - /** ISO timestamp — only set for likers (from backend LikerPreview). 46 - * supporters from atprotofans don't carry a timestamp. */ 47 - liked_at?: string; 48 - } 49 - 50 35 export interface Track { 51 36 id: number; 52 37 title: string; ··· 63 48 atproto_record_url?: string; 64 49 play_count: number; 65 50 like_count?: number; 66 - top_likers?: UserPreview[]; // up to 3 most recent likers, for inline avatar stack 67 51 comment_count?: number; 68 52 features?: FeaturedArtist[]; 69 53 tags?: string[];
+2
frontend/src/routes/+layout.svelte
··· 12 12 import LogoutModal from '$lib/components/LogoutModal.svelte'; 13 13 import FeedbackModal from '$lib/components/FeedbackModal.svelte'; 14 14 import TermsOverlay from '$lib/components/TermsOverlay.svelte'; 15 + import LikersSheet from '$lib/components/LikersSheet.svelte'; 15 16 import { onMount, onDestroy, untrack } from 'svelte'; 16 17 import { page } from '$app/stores'; 17 18 import { afterNavigate } from '$app/navigation'; ··· 499 500 <SearchModal /> 500 501 <LogoutModal /> 501 502 <FeedbackModal /> 503 + <LikersSheet /> 502 504 {#if showTermsOverlay} 503 505 <TermsOverlay /> 504 506 {/if}
+87 -12
frontend/src/routes/track/[id]/+page.svelte
··· 10 10 import AddToMenu from '$lib/components/AddToMenu.svelte'; 11 11 import TagEffects from '$lib/components/TagEffects.svelte'; 12 12 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 13 - import LikersStrip from '$lib/components/LikersStrip.svelte'; 13 + import LikersTooltip from '$lib/components/LikersTooltip.svelte'; 14 + import { likersSheet } from '$lib/likers-sheet.svelte'; 14 15 import LosslessBadge from '$lib/components/LosslessBadge.svelte'; 15 16 import RichText from '$lib/components/RichText.svelte'; 16 17 import ShareButton from '$lib/components/ShareButton.svelte'; ··· 74 75 player.currentTrack?.id === track.id && !player.paused 75 76 ); 76 77 78 + // mobile detection 79 + let isMobile = $state(false); 80 + 81 + $effect(() => { 82 + if (browser) { 83 + const mq = window.matchMedia('(max-width: 768px)'); 84 + isMobile = mq.matches; 85 + const handler = (e: MediaQueryListEvent) => (isMobile = e.matches); 86 + mq.addEventListener('change', handler); 87 + return () => mq.removeEventListener('change', handler); 88 + } 89 + }); 90 + 77 91 // metadata disclosure panel 78 92 let metadataOpen = $state(false); 79 93 94 + // likers tooltip state 95 + let showLikersTooltip = $state(false); 96 + let likersTooltipTimeout: ReturnType<typeof setTimeout> | null = null; 97 + 98 + function handleLikesMouseEnter() { 99 + if (isMobile) return; 100 + if (likersTooltipTimeout) { 101 + clearTimeout(likersTooltipTimeout); 102 + likersTooltipTimeout = null; 103 + } 104 + showLikersTooltip = true; 105 + } 106 + 107 + function handleLikesMouseLeave() { 108 + if (isMobile) return; 109 + likersTooltipTimeout = setTimeout(() => { 110 + showLikersTooltip = false; 111 + likersTooltipTimeout = null; 112 + }, 150); 113 + } 114 + 115 + function handleLikesClick() { 116 + if (isMobile && track.like_count) { 117 + likersSheet.open(track.id, track.like_count); 118 + } 119 + } 120 + 121 + function handleLikesKeydown(event: KeyboardEvent) { 122 + if (event.key === 'Enter' || event.key === ' ') { 123 + event.preventDefault(); 124 + if (isMobile && track.like_count) { 125 + likersSheet.open(track.id, track.like_count); 126 + } else { 127 + showLikersTooltip = true; 128 + } 129 + } 130 + if (event.key === 'Escape') { 131 + showLikersTooltip = false; 132 + } 133 + } 134 + 80 135 81 136 async function loadLikedState() { 82 137 try { ··· 542 597 <LosslessBadge originalFileType={track.original_file_type} fileType={track.file_type} withSeparator separatorClass="separator" /> 543 598 {#if track.like_count && track.like_count > 0} 544 599 <span class="separator">•</span> 545 - <span class="likes"> 546 - {#if track.top_likers && track.top_likers.length > 0} 547 - <LikersStrip 600 + <span 601 + class="likes" 602 + role="button" 603 + tabindex="0" 604 + aria-label={`${track.like_count} ${track.like_count === 1 ? 'like' : 'likes'} (focus to view users)`} 605 + aria-expanded={showLikersTooltip} 606 + onclick={handleLikesClick} 607 + onmouseenter={handleLikesMouseEnter} 608 + onmouseleave={handleLikesMouseLeave} 609 + onfocus={handleLikesMouseEnter} 610 + onblur={handleLikesMouseLeave} 611 + onkeydown={handleLikesKeydown} 612 + > 613 + {track.like_count} {track.like_count === 1 ? 'like' : 'likes'} 614 + {#if showLikersTooltip && !isMobile} 615 + <LikersTooltip 548 616 trackId={track.id} 549 617 likeCount={track.like_count} 550 - topLikers={track.top_likers} 551 - size={24} 552 - borderColor="var(--bg-primary)" 553 - maxScrollWidth="22rem" 618 + onMouseEnter={handleLikesMouseEnter} 619 + onMouseLeave={handleLikesMouseLeave} 554 620 /> 555 - {:else} 556 - {track.like_count} {track.like_count === 1 ? 'like' : 'likes'} 557 621 {/if} 558 622 </span> 559 623 {/if} ··· 950 1014 } 951 1015 952 1016 .track-stats .likes { 953 - display: inline-flex; 954 - align-items: center; 1017 + position: relative; 1018 + cursor: pointer; 1019 + padding: 0.125rem 0.25rem; 1020 + margin: -0.125rem -0.25rem; 1021 + border-radius: var(--radius-sm); 1022 + transition: background 0.15s, color 0.15s; 1023 + } 1024 + 1025 + .track-stats .likes:hover, 1026 + .track-stats .likes:focus { 1027 + background: color-mix(in srgb, var(--accent) 15%, transparent); 1028 + color: var(--accent); 1029 + outline: none; 955 1030 } 956 1031 957 1032 .metadata-toggle {
+85 -27
frontend/src/routes/u/[handle]/+page.svelte
··· 8 8 import ShareButton from '$lib/components/ShareButton.svelte'; 9 9 import Header from '$lib/components/Header.svelte'; 10 10 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 11 - import AvatarStack from '$lib/components/AvatarStack.svelte'; 12 11 import SupporterBadge from '$lib/components/SupporterBadge.svelte'; 13 12 import RichText from '$lib/components/RichText.svelte'; 14 13 import { moderation } from '$lib/moderation.svelte'; ··· 37 36 let nextCursor = $state<string | null>(data.nextCursor ?? null); 38 37 let loadingMoreTracks = $state(false); 39 38 let shareUrl = $state(''); 40 - let isMobile = $state(false); 41 - 42 - $effect(() => { 43 - if (!browser) return; 44 - const mq = window.matchMedia('(max-width: 768px)'); 45 - isMobile = mq.matches; 46 - const handler = (e: MediaQueryListEvent) => (isMobile = e.matches); 47 - mq.addEventListener('change', handler); 48 - return () => mq.removeEventListener('change', handler); 49 - }); 50 39 51 40 // compute support URL - handle 'atprotofans' magic value 52 41 const supportUrl = $derived(() => { ··· 471 460 </section> 472 461 473 462 {#if artist.support_url === 'atprotofans' && supporters.length > 0} 474 - {@const total = supporterCount ?? supporters.length} 475 463 <section class="supporters-section"> 476 464 <div class="supporters-row"> 477 - <span class="supporters-label">{total} {total === 1 ? 'supporter' : 'supporters'}</span> 478 - <AvatarStack 479 - users={supporters} 480 - {total} 481 - maxVisible={20} 482 - size={isMobile ? 28 : 32} 483 - borderColor="var(--bg-primary)" 484 - avatarHref={(s) => `/u/${s.handle}`} 485 - moreHref={supportUrl() ?? undefined} 486 - moreTarget="_blank" 487 - ariaLabel="view all supporters" 488 - class="supporters-avatars" 489 - /> 465 + <span class="supporters-label">{supporterCount ?? supporters.length} {(supporterCount ?? supporters.length) === 1 ? 'supporter' : 'supporters'}</span> 466 + <div class="supporters-avatars"> 467 + {#each supporters.slice(0, 20) as supporter} 468 + <a 469 + href="/u/{supporter.handle}" 470 + class="supporter-circle" 471 + title={supporter.display_name || supporter.handle} 472 + > 473 + {#if supporter.avatar_url} 474 + <img src={supporter.avatar_url} alt="" /> 475 + {:else} 476 + <span>{(supporter.display_name || supporter.handle).charAt(0).toUpperCase()}</span> 477 + {/if} 478 + </a> 479 + {/each} 480 + {#if (supporterCount ?? supporters.length) > 20} 481 + <a 482 + href={supportUrl()} 483 + target="_blank" 484 + rel="noopener" 485 + class="supporter-circle more" 486 + title="view all supporters" 487 + > 488 + +{(supporterCount ?? supporters.length) - 20} 489 + </a> 490 + {/if} 491 + </div> 490 492 </div> 491 493 </section> 492 494 {/if} ··· 844 846 white-space: nowrap; 845 847 } 846 848 847 - /* overlapping avatar strip is rendered by <AvatarStack>. styles live in 848 - that component; this page just needs the row-level flex layout. */ 849 + .supporters-avatars { 850 + display: flex; 851 + align-items: center; 852 + } 853 + 854 + .supporter-circle { 855 + width: 32px; 856 + height: 32px; 857 + border-radius: var(--radius-full); 858 + border: 2px solid var(--bg-primary); 859 + background: var(--bg-tertiary); 860 + display: flex; 861 + align-items: center; 862 + justify-content: center; 863 + overflow: hidden; 864 + margin-left: -8px; 865 + transition: transform 0.15s ease, z-index 0.15s ease; 866 + position: relative; 867 + text-decoration: none; 868 + } 869 + 870 + .supporter-circle:first-child { 871 + margin-left: 0; 872 + } 873 + 874 + .supporter-circle:hover { 875 + transform: translateY(-2px) scale(1.1); 876 + z-index: 10; 877 + } 878 + 879 + .supporter-circle img { 880 + width: 100%; 881 + height: 100%; 882 + object-fit: cover; 883 + } 884 + 885 + .supporter-circle span { 886 + font-size: var(--text-xs); 887 + font-weight: 600; 888 + color: var(--text-secondary); 889 + } 890 + 891 + .supporter-circle.more { 892 + background: var(--bg-secondary); 893 + font-size: var(--text-xs); 894 + font-weight: 600; 895 + color: var(--text-tertiary); 896 + } 897 + 898 + .supporter-circle.more:hover { 899 + color: var(--accent); 900 + } 849 901 850 902 .analytics { 851 903 margin-bottom: 3rem; ··· 1274 1326 1275 1327 .album-card-meta p { 1276 1328 font-size: var(--text-sm); 1329 + } 1330 + 1331 + .supporter-circle { 1332 + width: 28px; 1333 + height: 28px; 1334 + margin-left: -6px; 1277 1335 } 1278 1336 } 1279 1337
+1 -1
loq.toml
··· 32 32 33 33 [[rules]] 34 34 path = "backend/src/backend/api/tracks/listing.py" 35 - max_lines = 592 35 + max_lines = 584 36 36 37 37 [[rules]] 38 38 path = "backend/src/backend/api/tracks/mutations.py"