feat: album management improvements (#550)

* fix: album delete endpoint and track ordering from ATProto list

fixes two user-reported issues:

1. album deletion: adds DELETE /albums/{album_id} endpoint
- by default orphans tracks (sets album_id to null)
- with ?cascade=true deletes all tracks in the album
- cleans up ATProto list record and cover image

2. album track ordering: respects ATProto list record order
- get_album now fetches the ATProto list record to determine track order
- falls back to created_at order if no ATProto record exists
- fixes issue where reordering tracks in the frontend didn't persist

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: album edit mode with ATProto sync and portal UX improvements

- add PATCH /albums/{album_id} endpoint for title/description updates
- sync album title changes to all track ATProto records
- add DELETE /albums/{album_id}/tracks/{track_id} to remove tracks from albums
- implement album page edit mode (inline title, cover upload, delete album)
- simplify portal albums section to clickable links (matches playlist UX)
- fix icon buttons to use square shape matching playlist page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 6aca63c4 308429f5

Changed files
+1558 -376
backend
src
backend
api
tests
frontend
src
routes
portal
u
[handle]
album
[slug]
+269 -23
backend/src/backend/api/albums.py
··· 2 3 import asyncio 4 import contextlib 5 from io import BytesIO 6 from pathlib import Path 7 from typing import Annotated 8 9 - from fastapi import APIRouter, Cookie, Depends, File, HTTPException, Request, UploadFile 10 from pydantic import BaseModel 11 from sqlalchemy import func, select 12 from sqlalchemy.ext.asyncio import AsyncSession ··· 24 get_track_tags, 25 ) 26 from backend.utilities.hashing import CHUNK_SIZE 27 28 router = APIRouter(prefix="/albums", tags=["albums"]) 29 ··· 246 request: Request, 247 session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 248 ) -> AlbumResponse: 249 - """get album details with all tracks for a specific artist.""" 250 # look up artist + album 251 album_result = await db.execute( 252 select(Album, Artist) ··· 259 260 album, artist = row 261 262 - # batch fetch like counts 263 track_stmt = ( 264 select(Track) 265 .options(selectinload(Track.artist), selectinload(Track.album_rel)) 266 .where(Track.album_id == album.id) 267 - .order_by(Track.created_at.asc()) 268 ) 269 track_result = await db.execute(track_stmt) 270 - tracks = track_result.scalars().all() 271 track_ids = [track.id for track in tracks] 272 if track_ids: 273 like_counts, comment_counts, track_tags = await asyncio.gather( 274 get_like_counts(db, track_ids), ··· 293 ) 294 liked_track_ids = set(liked_result.scalars().all()) 295 296 - # ensure PDS URL cached 297 - pds_cache: dict[str, str | None] = {} 298 - if artist.pds_url: 299 - pds_cache[artist.did] = artist.pds_url 300 - else: 301 - from atproto_identity.did.resolver import AsyncDidResolver 302 - 303 - resolver = AsyncDidResolver() 304 - try: 305 - atproto_data = await resolver.resolve_atproto_data(artist.did) 306 - pds_cache[artist.did] = atproto_data.pds 307 - artist.pds_url = atproto_data.pds 308 - db.add(artist) 309 - await db.commit() 310 - except Exception: 311 - pds_cache[artist.did] = None 312 - 313 - # build track responses 314 track_responses = await asyncio.gather( 315 *[ 316 TrackResponse.from_track( ··· 401 raise HTTPException( 402 status_code=500, detail=f"failed to upload image: {e!s}" 403 ) from e
··· 2 3 import asyncio 4 import contextlib 5 + import logging 6 from io import BytesIO 7 from pathlib import Path 8 from typing import Annotated 9 10 + from fastapi import ( 11 + APIRouter, 12 + Cookie, 13 + Depends, 14 + File, 15 + HTTPException, 16 + Query, 17 + Request, 18 + UploadFile, 19 + ) 20 from pydantic import BaseModel 21 from sqlalchemy import func, select 22 from sqlalchemy.ext.asyncio import AsyncSession ··· 34 get_track_tags, 35 ) 36 from backend.utilities.hashing import CHUNK_SIZE 37 + 38 + logger = logging.getLogger(__name__) 39 40 router = APIRouter(prefix="/albums", tags=["albums"]) 41 ··· 258 request: Request, 259 session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 260 ) -> AlbumResponse: 261 + """get album details with all tracks for a specific artist. 262 + 263 + if the album has an ATProto list record, tracks are returned in the 264 + order stored in that record. otherwise, tracks are ordered by created_at. 265 + """ 266 + from backend._internal.atproto.records import get_record_public 267 + 268 # look up artist + album 269 album_result = await db.execute( 270 select(Album, Artist) ··· 277 278 album, artist = row 279 280 + # ensure PDS URL cached (needed for ATProto record fetch) 281 + pds_cache: dict[str, str | None] = {} 282 + if artist.pds_url: 283 + pds_cache[artist.did] = artist.pds_url 284 + else: 285 + from atproto_identity.did.resolver import AsyncDidResolver 286 + 287 + resolver = AsyncDidResolver() 288 + try: 289 + atproto_data = await resolver.resolve_atproto_data(artist.did) 290 + pds_cache[artist.did] = atproto_data.pds 291 + artist.pds_url = atproto_data.pds 292 + db.add(artist) 293 + await db.commit() 294 + except Exception: 295 + pds_cache[artist.did] = None 296 + 297 + # fetch all tracks for this album 298 track_stmt = ( 299 select(Track) 300 .options(selectinload(Track.artist), selectinload(Track.album_rel)) 301 .where(Track.album_id == album.id) 302 ) 303 track_result = await db.execute(track_stmt) 304 + all_tracks = list(track_result.scalars().all()) 305 + 306 + # determine track order: use ATProto list record if available 307 + ordered_tracks: list[Track] = [] 308 + if album.atproto_record_uri and artist.pds_url: 309 + try: 310 + record_data = await get_record_public( 311 + record_uri=album.atproto_record_uri, 312 + pds_url=artist.pds_url, 313 + ) 314 + items = record_data.get("value", {}).get("items", []) 315 + track_uris = [item.get("subject", {}).get("uri") for item in items] 316 + track_uris = [uri for uri in track_uris if uri] 317 + 318 + # build uri -> track map 319 + track_by_uri = {t.atproto_record_uri: t for t in all_tracks} 320 + 321 + # order tracks by ATProto list, append any not in list at end 322 + seen_ids = set() 323 + for uri in track_uris: 324 + if uri in track_by_uri: 325 + track = track_by_uri[uri] 326 + ordered_tracks.append(track) 327 + seen_ids.add(track.id) 328 + 329 + # append any tracks not in the ATProto list (fallback) 330 + for track in sorted(all_tracks, key=lambda t: t.created_at): 331 + if track.id not in seen_ids: 332 + ordered_tracks.append(track) 333 + 334 + except Exception as e: 335 + logger.warning(f"failed to fetch ATProto list for album ordering: {e}") 336 + # fallback to created_at order 337 + ordered_tracks = sorted(all_tracks, key=lambda t: t.created_at) 338 + else: 339 + # no ATProto record - order by created_at 340 + ordered_tracks = sorted(all_tracks, key=lambda t: t.created_at) 341 + 342 + tracks = ordered_tracks 343 track_ids = [track.id for track in tracks] 344 + 345 + # batch fetch aggregations 346 if track_ids: 347 like_counts, comment_counts, track_tags = await asyncio.gather( 348 get_like_counts(db, track_ids), ··· 367 ) 368 liked_track_ids = set(liked_result.scalars().all()) 369 370 + # build track responses (maintaining order) 371 track_responses = await asyncio.gather( 372 *[ 373 TrackResponse.from_track( ··· 458 raise HTTPException( 459 status_code=500, detail=f"failed to upload image: {e!s}" 460 ) from e 461 + 462 + 463 + @router.patch("/{album_id}") 464 + async def update_album( 465 + album_id: str, 466 + db: Annotated[AsyncSession, Depends(get_db)], 467 + auth_session: Annotated[AuthSession, Depends(require_artist_profile)], 468 + title: Annotated[str | None, Query(description="new album title")] = None, 469 + description: Annotated[ 470 + str | None, Query(description="new album description") 471 + ] = None, 472 + ) -> AlbumMetadata: 473 + """update album metadata (title, description). 474 + 475 + when title changes, all tracks in the album have their ATProto records 476 + updated to reflect the new album name. 477 + """ 478 + from backend._internal.atproto.records.fm_plyr.track import ( 479 + build_track_record, 480 + update_record, 481 + ) 482 + 483 + result = await db.execute( 484 + select(Album) 485 + .where(Album.id == album_id) 486 + .options(selectinload(Album.tracks).selectinload(Track.artist)) 487 + ) 488 + album = result.scalar_one_or_none() 489 + if not album: 490 + raise HTTPException(status_code=404, detail="album not found") 491 + if album.artist_did != auth_session.did: 492 + raise HTTPException( 493 + status_code=403, detail="you can only update your own albums" 494 + ) 495 + 496 + old_title = album.title 497 + title_changed = title is not None and title.strip() != old_title 498 + 499 + if title is not None: 500 + album.title = title.strip() 501 + if description is not None: 502 + album.description = description.strip() if description.strip() else None 503 + 504 + # if title changed, update all tracks' extra["album"] and ATProto records 505 + if title_changed and title is not None: 506 + new_title = title.strip() 507 + 508 + for track in album.tracks: 509 + # update the track's extra["album"] field 510 + if track.extra is None: 511 + track.extra = {} 512 + track.extra = {**track.extra, "album": new_title} 513 + 514 + # update ATProto record 515 + updated_record = build_track_record( 516 + title=track.title, 517 + artist=track.artist.display_name, 518 + audio_url=track.r2_url, 519 + file_type=track.file_type, 520 + album=new_title, 521 + duration=track.duration, 522 + features=track.features if track.features else None, 523 + image_url=await track.get_image_url(), 524 + ) 525 + 526 + _, new_cid = await update_record( 527 + auth_session=auth_session, 528 + record_uri=track.atproto_record_uri, 529 + record=updated_record, 530 + ) 531 + track.atproto_record_cid = new_cid 532 + 533 + await db.commit() 534 + 535 + # fetch artist for response 536 + artist_result = await db.execute( 537 + select(Artist).where(Artist.did == album.artist_did) 538 + ) 539 + artist = artist_result.scalar_one() 540 + track_count, total_plays = await _album_stats(db, album_id) 541 + 542 + return await _album_metadata(album, artist, track_count, total_plays) 543 + 544 + 545 + @router.delete("/{album_id}/tracks/{track_id}") 546 + async def remove_track_from_album( 547 + album_id: str, 548 + track_id: int, 549 + db: Annotated[AsyncSession, Depends(get_db)], 550 + auth_session: Annotated[AuthSession, Depends(require_artist_profile)], 551 + ) -> dict: 552 + """remove a track from an album (orphan it, don't delete). 553 + 554 + the track remains available as a standalone track. 555 + """ 556 + # verify album exists and belongs to the authenticated artist 557 + album_result = await db.execute(select(Album).where(Album.id == album_id)) 558 + album = album_result.scalar_one_or_none() 559 + if not album: 560 + raise HTTPException(status_code=404, detail="album not found") 561 + if album.artist_did != auth_session.did: 562 + raise HTTPException( 563 + status_code=403, detail="you can only modify your own albums" 564 + ) 565 + 566 + # verify track exists and is in this album 567 + track_result = await db.execute(select(Track).where(Track.id == track_id)) 568 + track = track_result.scalar_one_or_none() 569 + if not track: 570 + raise HTTPException(status_code=404, detail="track not found") 571 + if track.album_id != album_id: 572 + raise HTTPException(status_code=400, detail="track is not in this album") 573 + 574 + # orphan the track 575 + track.album_id = None 576 + await db.commit() 577 + 578 + return {"removed": True, "track_id": track_id} 579 + 580 + 581 + @router.delete("/{album_id}") 582 + async def delete_album( 583 + album_id: str, 584 + db: Annotated[AsyncSession, Depends(get_db)], 585 + auth_session: Annotated[AuthSession, Depends(require_artist_profile)], 586 + cascade: Annotated[ 587 + bool, 588 + Query(description="if true, also delete all tracks in the album"), 589 + ] = False, 590 + ) -> dict: 591 + """delete an album. 592 + 593 + by default, tracks are orphaned (album_id set to null) and remain 594 + available as standalone tracks. with cascade=true, tracks are also deleted. 595 + 596 + also deletes the ATProto list record if one exists. 597 + """ 598 + from backend._internal.atproto.records import delete_record_by_uri 599 + 600 + # verify album exists and belongs to the authenticated artist 601 + result = await db.execute(select(Album).where(Album.id == album_id)) 602 + album = result.scalar_one_or_none() 603 + if not album: 604 + raise HTTPException(status_code=404, detail="album not found") 605 + if album.artist_did != auth_session.did: 606 + raise HTTPException( 607 + status_code=403, detail="you can only delete your own albums" 608 + ) 609 + 610 + # handle tracks 611 + if cascade: 612 + # delete all tracks in album 613 + from backend.api.tracks.mutations import delete_track 614 + 615 + tracks_result = await db.execute( 616 + select(Track).where(Track.album_id == album_id) 617 + ) 618 + tracks = tracks_result.scalars().all() 619 + for track in tracks: 620 + try: 621 + await delete_track(track.id, db, auth_session) 622 + except Exception as e: 623 + logger.warning(f"failed to delete track {track.id}: {e}") 624 + else: 625 + # orphan tracks - set album_id to null 626 + from sqlalchemy import update 627 + 628 + await db.execute( 629 + update(Track).where(Track.album_id == album_id).values(album_id=None) 630 + ) 631 + 632 + # delete ATProto record if exists 633 + if album.atproto_record_uri: 634 + try: 635 + await delete_record_by_uri(auth_session, album.atproto_record_uri) 636 + except Exception as e: 637 + logger.warning(f"failed to delete ATProto record: {e}") 638 + # continue with database cleanup even if ATProto delete fails 639 + 640 + # delete cover image from storage if exists 641 + if album.image_id: 642 + with contextlib.suppress(Exception): 643 + await storage.delete(album.image_id) 644 + 645 + # delete album from database 646 + await db.delete(album) 647 + await db.commit() 648 + 649 + return {"deleted": True, "cascade": cascade}
+642 -1
backend/tests/api/test_albums.py
··· 1 """tests for album API helpers.""" 2 3 from collections.abc import Generator 4 5 import pytest 6 from fastapi import FastAPI 7 from httpx import ASGITransport, AsyncClient 8 from sqlalchemy.ext.asyncio import AsyncSession 9 10 from backend._internal import Session ··· 18 19 def __init__(self, did: str = "did:test:user123"): 20 self.did = did 21 self.access_token = "test_token" 22 self.refresh_token = "test_refresh" 23 24 25 @pytest.fixture 26 def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 27 """create test app with mocked auth.""" 28 - from backend._internal import require_auth 29 30 async def mock_require_auth() -> Session: 31 return MockSession() 32 33 app.dependency_overrides[require_auth] = mock_require_auth 34 35 yield app 36 ··· 187 assert data["tracks"][0]["album"]["id"] == album.id 188 assert data["tracks"][0]["album"]["slug"] == "test-album" 189 assert data["tracks"][0]["album"]["title"] == "Test Album"
··· 1 """tests for album API helpers.""" 2 3 from collections.abc import Generator 4 + from unittest.mock import AsyncMock, patch 5 6 import pytest 7 from fastapi import FastAPI 8 from httpx import ASGITransport, AsyncClient 9 + from sqlalchemy import select 10 from sqlalchemy.ext.asyncio import AsyncSession 11 12 from backend._internal import Session ··· 20 21 def __init__(self, did: str = "did:test:user123"): 22 self.did = did 23 + self.handle = "testuser.bsky.social" 24 + self.session_id = "test_session_id" 25 self.access_token = "test_token" 26 self.refresh_token = "test_refresh" 27 + self.oauth_session = { 28 + "did": did, 29 + "handle": "testuser.bsky.social", 30 + "pds_url": "https://test.pds", 31 + "authserver_iss": "https://auth.test", 32 + "scope": "atproto transition:generic", 33 + "access_token": "test_token", 34 + "refresh_token": "test_refresh", 35 + "dpop_private_key_pem": "fake_key", 36 + "dpop_authserver_nonce": "", 37 + "dpop_pds_nonce": "", 38 + } 39 40 41 @pytest.fixture 42 def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 43 """create test app with mocked auth.""" 44 + from backend._internal import require_artist_profile, require_auth 45 46 async def mock_require_auth() -> Session: 47 return MockSession() 48 49 + async def mock_require_artist_profile() -> Session: 50 + return MockSession() 51 + 52 app.dependency_overrides[require_auth] = mock_require_auth 53 + app.dependency_overrides[require_artist_profile] = mock_require_artist_profile 54 55 yield app 56 ··· 207 assert data["tracks"][0]["album"]["id"] == album.id 208 assert data["tracks"][0]["album"]["slug"] == "test-album" 209 assert data["tracks"][0]["album"]["title"] == "Test Album" 210 + 211 + 212 + async def test_delete_album_orphans_tracks_by_default( 213 + test_app: FastAPI, db_session: AsyncSession 214 + ): 215 + """test that deleting album orphans tracks (sets album_id to null). 216 + 217 + regression test for user report: unable to delete empty album after 218 + deleting individual tracks. 219 + """ 220 + # create artist matching mock session 221 + artist = Artist( 222 + did="did:test:user123", 223 + handle="test.artist", 224 + display_name="Test Artist", 225 + ) 226 + db_session.add(artist) 227 + await db_session.flush() 228 + 229 + # create album 230 + album = Album( 231 + artist_did=artist.did, 232 + slug="test-album", 233 + title="Test Album", 234 + ) 235 + db_session.add(album) 236 + await db_session.flush() 237 + 238 + # create tracks linked to album 239 + track1 = Track( 240 + title="Track 1", 241 + file_id="test-file-1", 242 + file_type="audio/mpeg", 243 + artist_did=artist.did, 244 + album_id=album.id, 245 + ) 246 + track2 = Track( 247 + title="Track 2", 248 + file_id="test-file-2", 249 + file_type="audio/mpeg", 250 + artist_did=artist.did, 251 + album_id=album.id, 252 + ) 253 + db_session.add_all([track1, track2]) 254 + await db_session.commit() 255 + 256 + album_id = album.id 257 + track1_id = track1.id 258 + track2_id = track2.id 259 + 260 + # mock ATProto delete (imported inside the function from _internal.atproto.records) 261 + with patch( 262 + "backend._internal.atproto.records.fm_plyr.track.delete_record_by_uri", 263 + new_callable=AsyncMock, 264 + ): 265 + async with AsyncClient( 266 + transport=ASGITransport(app=test_app), base_url="http://test" 267 + ) as client: 268 + response = await client.delete(f"/albums/{album_id}") 269 + 270 + assert response.status_code == 200 271 + data = response.json() 272 + assert data["deleted"] is True 273 + assert data["cascade"] is False 274 + 275 + # close test session and create fresh one to read committed data 276 + await db_session.close() 277 + 278 + # verify album is deleted by checking response 279 + # (the API committed in a separate session, test session can't see it) 280 + # Use a fresh query to verify the state 281 + from backend.utilities.database import get_engine 282 + 283 + engine = get_engine() 284 + async with AsyncSession(engine, expire_on_commit=False) as fresh_session: 285 + # verify album is deleted 286 + result = await fresh_session.execute(select(Album).where(Album.id == album_id)) 287 + assert result.scalar_one_or_none() is None 288 + 289 + # verify tracks still exist but are orphaned (album_id = null) 290 + result = await fresh_session.execute(select(Track).where(Track.id == track1_id)) 291 + track1_after = result.scalar_one_or_none() 292 + assert track1_after is not None 293 + assert track1_after.album_id is None 294 + 295 + result = await fresh_session.execute(select(Track).where(Track.id == track2_id)) 296 + track2_after = result.scalar_one_or_none() 297 + assert track2_after is not None 298 + assert track2_after.album_id is None 299 + 300 + 301 + async def test_delete_album_cascade_deletes_tracks( 302 + test_app: FastAPI, db_session: AsyncSession 303 + ): 304 + """test that deleting album with cascade=true also deletes all tracks.""" 305 + # create artist matching mock session 306 + artist = Artist( 307 + did="did:test:user123", 308 + handle="test.artist", 309 + display_name="Test Artist", 310 + ) 311 + db_session.add(artist) 312 + await db_session.flush() 313 + 314 + # create album 315 + album = Album( 316 + artist_did=artist.did, 317 + slug="test-album-cascade", 318 + title="Test Album Cascade", 319 + ) 320 + db_session.add(album) 321 + await db_session.flush() 322 + 323 + # create tracks linked to album 324 + track1 = Track( 325 + title="Track 1", 326 + file_id="cascade-file-1", 327 + file_type="audio/mpeg", 328 + artist_did=artist.did, 329 + album_id=album.id, 330 + ) 331 + track2 = Track( 332 + title="Track 2", 333 + file_id="cascade-file-2", 334 + file_type="audio/mpeg", 335 + artist_did=artist.did, 336 + album_id=album.id, 337 + ) 338 + db_session.add_all([track1, track2]) 339 + await db_session.commit() 340 + 341 + album_id = album.id 342 + track1_id = track1.id 343 + track2_id = track2.id 344 + 345 + # mock ATProto and storage deletes 346 + with ( 347 + patch( 348 + "backend._internal.atproto.records.fm_plyr.track.delete_record_by_uri", 349 + new_callable=AsyncMock, 350 + ), 351 + patch( 352 + "backend.api.tracks.mutations.delete_record_by_uri", 353 + new_callable=AsyncMock, 354 + ), 355 + patch( 356 + "backend.api.tracks.mutations.storage.delete", 357 + new_callable=AsyncMock, 358 + ), 359 + ): 360 + async with AsyncClient( 361 + transport=ASGITransport(app=test_app), base_url="http://test" 362 + ) as client: 363 + response = await client.delete(f"/albums/{album_id}?cascade=true") 364 + 365 + assert response.status_code == 200 366 + data = response.json() 367 + assert data["deleted"] is True 368 + assert data["cascade"] is True 369 + 370 + # verify album is deleted 371 + result = await db_session.execute(select(Album).where(Album.id == album_id)) 372 + assert result.scalar_one_or_none() is None 373 + 374 + # verify tracks are also deleted 375 + result = await db_session.execute(select(Track).where(Track.id == track1_id)) 376 + assert result.scalar_one_or_none() is None 377 + 378 + result = await db_session.execute(select(Track).where(Track.id == track2_id)) 379 + assert result.scalar_one_or_none() is None 380 + 381 + 382 + async def test_delete_album_forbidden_for_non_owner( 383 + test_app: FastAPI, db_session: AsyncSession 384 + ): 385 + """test that users cannot delete albums they don't own.""" 386 + # create a different artist (not the mock session's did) 387 + other_artist = Artist( 388 + did="did:other:artist999", 389 + handle="other.artist", 390 + display_name="Other Artist", 391 + ) 392 + db_session.add(other_artist) 393 + await db_session.flush() 394 + 395 + # create album owned by other artist 396 + album = Album( 397 + artist_did=other_artist.did, 398 + slug="other-album", 399 + title="Other Album", 400 + ) 401 + db_session.add(album) 402 + await db_session.commit() 403 + 404 + album_id = album.id 405 + 406 + async with AsyncClient( 407 + transport=ASGITransport(app=test_app), base_url="http://test" 408 + ) as client: 409 + response = await client.delete(f"/albums/{album_id}") 410 + 411 + assert response.status_code == 403 412 + assert "your own albums" in response.json()["detail"] 413 + 414 + 415 + async def test_delete_empty_album(test_app: FastAPI, db_session: AsyncSession): 416 + """test deleting an album with no tracks (empty album shell). 417 + 418 + regression test for user report: after deleting all tracks individually, 419 + the album folder remains and cannot be deleted. 420 + """ 421 + # create artist matching mock session 422 + artist = Artist( 423 + did="did:test:user123", 424 + handle="test.artist", 425 + display_name="Test Artist", 426 + ) 427 + db_session.add(artist) 428 + await db_session.flush() 429 + 430 + # create empty album (no tracks) 431 + album = Album( 432 + artist_did=artist.did, 433 + slug="empty-album", 434 + title="Empty Album", 435 + ) 436 + db_session.add(album) 437 + await db_session.commit() 438 + 439 + album_id = album.id 440 + 441 + # mock ATProto delete 442 + with patch( 443 + "backend._internal.atproto.records.fm_plyr.track.delete_record_by_uri", 444 + new_callable=AsyncMock, 445 + ): 446 + async with AsyncClient( 447 + transport=ASGITransport(app=test_app), base_url="http://test" 448 + ) as client: 449 + response = await client.delete(f"/albums/{album_id}") 450 + 451 + assert response.status_code == 200 452 + data = response.json() 453 + assert data["deleted"] is True 454 + 455 + # verify album is deleted 456 + result = await db_session.execute(select(Album).where(Album.id == album_id)) 457 + assert result.scalar_one_or_none() is None 458 + 459 + 460 + async def test_get_album_respects_atproto_track_order( 461 + test_app: FastAPI, db_session: AsyncSession 462 + ): 463 + """test that get_album returns tracks in ATProto list order. 464 + 465 + regression test for user report: album track reorder doesn't persist. 466 + the frontend saves order to ATProto, but backend was ignoring it. 467 + """ 468 + from datetime import UTC, datetime, timedelta 469 + 470 + # create artist 471 + artist = Artist( 472 + did="did:test:user123", 473 + handle="test.artist", 474 + display_name="Test Artist", 475 + pds_url="https://test.pds", 476 + ) 477 + db_session.add(artist) 478 + await db_session.flush() 479 + 480 + # create album with ATProto record URI 481 + album = Album( 482 + artist_did=artist.did, 483 + slug="ordered-album", 484 + title="Ordered Album", 485 + atproto_record_uri="at://did:test:user123/fm.plyr.list/album123", 486 + ) 487 + db_session.add(album) 488 + await db_session.flush() 489 + 490 + # create tracks with staggered created_at (track3 first, track1 last) 491 + base_time = datetime.now(UTC) 492 + track1 = Track( 493 + title="Track 1", 494 + file_id="order-file-1", 495 + file_type="audio/mpeg", 496 + artist_did=artist.did, 497 + album_id=album.id, 498 + atproto_record_uri="at://did:test:user123/fm.plyr.track/track1", 499 + atproto_record_cid="cid1", 500 + created_at=base_time + timedelta(hours=2), # created last 501 + ) 502 + track2 = Track( 503 + title="Track 2", 504 + file_id="order-file-2", 505 + file_type="audio/mpeg", 506 + artist_did=artist.did, 507 + album_id=album.id, 508 + atproto_record_uri="at://did:test:user123/fm.plyr.track/track2", 509 + atproto_record_cid="cid2", 510 + created_at=base_time + timedelta(hours=1), # created second 511 + ) 512 + track3 = Track( 513 + title="Track 3", 514 + file_id="order-file-3", 515 + file_type="audio/mpeg", 516 + artist_did=artist.did, 517 + album_id=album.id, 518 + atproto_record_uri="at://did:test:user123/fm.plyr.track/track3", 519 + atproto_record_cid="cid3", 520 + created_at=base_time, # created first 521 + ) 522 + db_session.add_all([track1, track2, track3]) 523 + await db_session.commit() 524 + 525 + # mock ATProto record fetch to return custom order: track2, track3, track1 526 + # (different from created_at order which would be track3, track2, track1) 527 + mock_record = { 528 + "value": { 529 + "items": [ 530 + {"subject": {"uri": track2.atproto_record_uri, "cid": "cid2"}}, 531 + {"subject": {"uri": track3.atproto_record_uri, "cid": "cid3"}}, 532 + {"subject": {"uri": track1.atproto_record_uri, "cid": "cid1"}}, 533 + ] 534 + } 535 + } 536 + 537 + with patch( 538 + "backend._internal.atproto.records.get_record_public", 539 + new_callable=AsyncMock, 540 + return_value=mock_record, 541 + ): 542 + async with AsyncClient( 543 + transport=ASGITransport(app=test_app), base_url="http://test" 544 + ) as client: 545 + response = await client.get(f"/albums/{artist.handle}/{album.slug}") 546 + 547 + assert response.status_code == 200 548 + data = response.json() 549 + 550 + # verify tracks are in ATProto order (track2, track3, track1) 551 + # NOT created_at order (which would be track3, track2, track1) 552 + assert len(data["tracks"]) == 3 553 + assert data["tracks"][0]["title"] == "Track 2" 554 + assert data["tracks"][1]["title"] == "Track 3" 555 + assert data["tracks"][2]["title"] == "Track 1" 556 + 557 + 558 + async def test_get_album_fallback_to_created_at_without_atproto( 559 + test_app: FastAPI, db_session: AsyncSession 560 + ): 561 + """test that get_album falls back to created_at order without ATProto record.""" 562 + from datetime import UTC, datetime, timedelta 563 + 564 + # create artist 565 + artist = Artist( 566 + did="did:test:user123", 567 + handle="test.artist", 568 + display_name="Test Artist", 569 + ) 570 + db_session.add(artist) 571 + await db_session.flush() 572 + 573 + # create album WITHOUT ATProto record URI 574 + album = Album( 575 + artist_did=artist.did, 576 + slug="no-atproto-album", 577 + title="No ATProto Album", 578 + atproto_record_uri=None, # no ATProto record 579 + ) 580 + db_session.add(album) 581 + await db_session.flush() 582 + 583 + # create tracks with specific order 584 + base_time = datetime.now(UTC) 585 + track1 = Track( 586 + title="First Track", 587 + file_id="first-file", 588 + file_type="audio/mpeg", 589 + artist_did=artist.did, 590 + album_id=album.id, 591 + created_at=base_time, 592 + ) 593 + track2 = Track( 594 + title="Second Track", 595 + file_id="second-file", 596 + file_type="audio/mpeg", 597 + artist_did=artist.did, 598 + album_id=album.id, 599 + created_at=base_time + timedelta(hours=1), 600 + ) 601 + track3 = Track( 602 + title="Third Track", 603 + file_id="third-file", 604 + file_type="audio/mpeg", 605 + artist_did=artist.did, 606 + album_id=album.id, 607 + created_at=base_time + timedelta(hours=2), 608 + ) 609 + db_session.add_all([track1, track2, track3]) 610 + await db_session.commit() 611 + 612 + async with AsyncClient( 613 + transport=ASGITransport(app=test_app), base_url="http://test" 614 + ) as client: 615 + response = await client.get(f"/albums/{artist.handle}/{album.slug}") 616 + 617 + assert response.status_code == 200 618 + data = response.json() 619 + 620 + # verify tracks are in created_at order (default fallback) 621 + assert len(data["tracks"]) == 3 622 + assert data["tracks"][0]["title"] == "First Track" 623 + assert data["tracks"][1]["title"] == "Second Track" 624 + assert data["tracks"][2]["title"] == "Third Track" 625 + 626 + 627 + async def test_update_album_title(test_app: FastAPI, db_session: AsyncSession): 628 + """test updating album title via PATCH endpoint. 629 + 630 + verifies that: 631 + 1. album title is updated in database 632 + 2. track extra["album"] is updated for all tracks 633 + 3. ATProto records are updated for tracks that have them 634 + """ 635 + # create artist matching mock session 636 + artist = Artist( 637 + did="did:test:user123", 638 + handle="test.artist", 639 + display_name="Test Artist", 640 + ) 641 + db_session.add(artist) 642 + await db_session.flush() 643 + 644 + # create album 645 + album = Album( 646 + artist_did=artist.did, 647 + slug="test-album", 648 + title="Original Title", 649 + ) 650 + db_session.add(album) 651 + await db_session.flush() 652 + 653 + # create track with ATProto record 654 + track = Track( 655 + title="Test Track", 656 + file_id="test-file-update", 657 + file_type="audio/mpeg", 658 + artist_did=artist.did, 659 + album_id=album.id, 660 + extra={"album": "Original Title"}, 661 + atproto_record_uri="at://did:test:user123/fm.plyr.track/track123", 662 + atproto_record_cid="original_cid", 663 + ) 664 + db_session.add(track) 665 + await db_session.commit() 666 + 667 + album_id = album.id 668 + track_id = track.id 669 + 670 + # mock ATProto update_record 671 + with patch( 672 + "backend._internal.atproto.records.fm_plyr.track.update_record", 673 + new_callable=AsyncMock, 674 + return_value=("at://did:test:user123/fm.plyr.track/track123", "new_cid"), 675 + ) as mock_update: 676 + async with AsyncClient( 677 + transport=ASGITransport(app=test_app), base_url="http://test" 678 + ) as client: 679 + response = await client.patch(f"/albums/{album_id}?title=Updated%20Title") 680 + 681 + assert response.status_code == 200 682 + data = response.json() 683 + assert data["title"] == "Updated Title" 684 + assert data["id"] == album_id 685 + 686 + # verify ATProto update was called 687 + mock_update.assert_called_once() 688 + call_kwargs = mock_update.call_args.kwargs 689 + assert call_kwargs["record"]["album"] == "Updated Title" 690 + 691 + # verify track extra["album"] was updated in database 692 + from backend.utilities.database import get_engine 693 + 694 + engine = get_engine() 695 + async with AsyncSession(engine, expire_on_commit=False) as fresh_session: 696 + result = await fresh_session.execute(select(Track).where(Track.id == track_id)) 697 + updated_track = result.scalar_one() 698 + assert updated_track.extra["album"] == "Updated Title" 699 + assert updated_track.atproto_record_cid == "new_cid" 700 + 701 + 702 + async def test_update_album_forbidden_for_non_owner( 703 + test_app: FastAPI, db_session: AsyncSession 704 + ): 705 + """test that users cannot update albums they don't own.""" 706 + # create a different artist 707 + other_artist = Artist( 708 + did="did:other:artist999", 709 + handle="other.artist", 710 + display_name="Other Artist", 711 + ) 712 + db_session.add(other_artist) 713 + await db_session.flush() 714 + 715 + # create album owned by other artist 716 + album = Album( 717 + artist_did=other_artist.did, 718 + slug="other-album", 719 + title="Other Album", 720 + ) 721 + db_session.add(album) 722 + await db_session.commit() 723 + 724 + album_id = album.id 725 + 726 + async with AsyncClient( 727 + transport=ASGITransport(app=test_app), base_url="http://test" 728 + ) as client: 729 + response = await client.patch(f"/albums/{album_id}?title=Hacked%20Title") 730 + 731 + assert response.status_code == 403 732 + assert "your own albums" in response.json()["detail"] 733 + 734 + 735 + async def test_remove_track_from_album(test_app: FastAPI, db_session: AsyncSession): 736 + """test removing a track from an album (orphaning it).""" 737 + # create artist matching mock session 738 + artist = Artist( 739 + did="did:test:user123", 740 + handle="test.artist", 741 + display_name="Test Artist", 742 + ) 743 + db_session.add(artist) 744 + await db_session.flush() 745 + 746 + # create album 747 + album = Album( 748 + artist_did=artist.did, 749 + slug="test-album", 750 + title="Test Album", 751 + ) 752 + db_session.add(album) 753 + await db_session.flush() 754 + 755 + # create track in album 756 + track = Track( 757 + title="Track to Remove", 758 + file_id="remove-file-1", 759 + file_type="audio/mpeg", 760 + artist_did=artist.did, 761 + album_id=album.id, 762 + ) 763 + db_session.add(track) 764 + await db_session.commit() 765 + 766 + album_id = album.id 767 + track_id = track.id 768 + 769 + async with AsyncClient( 770 + transport=ASGITransport(app=test_app), base_url="http://test" 771 + ) as client: 772 + response = await client.delete(f"/albums/{album_id}/tracks/{track_id}") 773 + 774 + assert response.status_code == 200 775 + data = response.json() 776 + assert data["removed"] is True 777 + assert data["track_id"] == track_id 778 + 779 + # verify track is orphaned (album_id = null) 780 + from backend.utilities.database import get_engine 781 + 782 + engine = get_engine() 783 + async with AsyncSession(engine, expire_on_commit=False) as fresh_session: 784 + result = await fresh_session.execute(select(Track).where(Track.id == track_id)) 785 + track_after = result.scalar_one_or_none() 786 + assert track_after is not None 787 + assert track_after.album_id is None 788 + 789 + 790 + async def test_remove_track_not_in_album(test_app: FastAPI, db_session: AsyncSession): 791 + """test that removing a track not in the album returns 400.""" 792 + # create artist 793 + artist = Artist( 794 + did="did:test:user123", 795 + handle="test.artist", 796 + display_name="Test Artist", 797 + ) 798 + db_session.add(artist) 799 + await db_session.flush() 800 + 801 + # create album 802 + album = Album( 803 + artist_did=artist.did, 804 + slug="test-album", 805 + title="Test Album", 806 + ) 807 + db_session.add(album) 808 + await db_session.flush() 809 + 810 + # create track NOT in this album (orphaned) 811 + track = Track( 812 + title="Orphan Track", 813 + file_id="orphan-file-1", 814 + file_type="audio/mpeg", 815 + artist_did=artist.did, 816 + album_id=None, # not in any album 817 + ) 818 + db_session.add(track) 819 + await db_session.commit() 820 + 821 + album_id = album.id 822 + track_id = track.id 823 + 824 + async with AsyncClient( 825 + transport=ASGITransport(app=test_app), base_url="http://test" 826 + ) as client: 827 + response = await client.delete(f"/albums/{album_id}/tracks/{track_id}") 828 + 829 + assert response.status_code == 400 830 + assert "not in this album" in response.json()["detail"]
+25 -231
frontend/src/routes/portal/+page.svelte
··· 40 // album management state 41 let albums = $state<AlbumSummary[]>([]); 42 let loadingAlbums = $state(false); 43 - let editingAlbumId = $state<string | null>(null); 44 - let editAlbumCoverFile = $state<File | null>(null); 45 46 // playlist management state 47 let playlists = $state<Playlist[]>([]); ··· 184 } finally { 185 loadingPlaylists = false; 186 } 187 - } 188 - 189 - async function uploadAlbumCover(albumId: string) { 190 - if (!editAlbumCoverFile) { 191 - toast.error('no cover art selected'); 192 - return; 193 - } 194 - 195 - const formData = new FormData(); 196 - formData.append('image', editAlbumCoverFile); 197 - 198 - try { 199 - const response = await fetch(`${API_URL}/albums/${albumId}/cover`, { 200 - method: 'POST', 201 - credentials: 'include', 202 - body: formData 203 - }); 204 - 205 - if (response.ok) { 206 - toast.success('album cover updated'); 207 - editingAlbumId = null; 208 - editAlbumCoverFile = null; 209 - await loadMyAlbums(); 210 - } else { 211 - const data = await response.json(); 212 - toast.error(data.detail || 'failed to upload cover'); 213 - } 214 - } catch (_e) { 215 - console.error('failed to upload album cover:', _e); 216 - toast.error('failed to upload cover art'); 217 - } 218 - } 219 - 220 - function startEditingAlbum(albumId: string) { 221 - editingAlbumId = albumId; 222 - editAlbumCoverFile = null; 223 - } 224 - 225 - function cancelEditAlbum() { 226 - editingAlbumId = null; 227 - editAlbumCoverFile = null; 228 } 229 230 async function saveProfile(e: SubmitEvent) { ··· 822 {:else} 823 <div class="albums-grid"> 824 {#each albums as album} 825 - <div class="album-card" class:editing={editingAlbumId === album.id}> 826 - {#if editingAlbumId === album.id} 827 - <div class="album-edit-container"> 828 - <div class="album-edit-preview"> 829 - {#if album.image_url && !editAlbumCoverFile} 830 - <img src={album.image_url} alt="{album.title} cover" class="album-cover" /> 831 - {:else if editAlbumCoverFile} 832 - <div class="album-cover-placeholder"> 833 - <span class="file-name">{editAlbumCoverFile.name}</span> 834 - <span class="file-size">({(editAlbumCoverFile.size / 1024 / 1024).toFixed(2)} MB)</span> 835 - </div> 836 - {:else} 837 - <div class="album-cover-placeholder"> 838 - <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 839 - <rect x="3" y="3" width="18" height="18" stroke="currentColor" stroke-width="1.5" fill="none"/> 840 - <circle cx="12" cy="12" r="4" fill="currentColor"/> 841 - </svg> 842 - </div> 843 - {/if} 844 - </div> 845 - <div class="album-edit-actions"> 846 - <label for="album-cover-input-{album.id}" class="file-input-label"> 847 - select album artwork 848 - </label> 849 - <input 850 - id="album-cover-input-{album.id}" 851 - type="file" 852 - accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" 853 - onchange={(e) => { 854 - const target = e.target as HTMLInputElement; 855 - editAlbumCoverFile = target.files?.[0] ?? null; 856 - }} 857 - class="file-input" 858 - /> 859 - <div class="edit-buttons"> 860 - <button 861 - class="action-btn save-btn" 862 - onclick={() => uploadAlbumCover(album.id)} 863 - title="upload cover" 864 - disabled={!editAlbumCoverFile} 865 - > 866 - 867 - </button> 868 - <button 869 - class="action-btn cancel-btn" 870 - onclick={cancelEditAlbum} 871 - title="cancel" 872 - > 873 - 874 - </button> 875 - </div> 876 - </div> 877 - </div> 878 {:else} 879 - <div class="album-cover-container"> 880 - {#if album.image_url} 881 - <img src={album.image_url} alt="{album.title} cover" class="album-cover" /> 882 - {:else} 883 - <div class="album-cover-placeholder"> 884 - <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 885 - <rect x="3" y="3" width="18" height="18" stroke="currentColor" stroke-width="1.5" fill="none"/> 886 - <circle cx="12" cy="12" r="4" fill="currentColor"/> 887 - </svg> 888 - </div> 889 - {/if} 890 - </div> 891 - <div class="album-info"> 892 - <h3 class="album-title">{album.title}</h3> 893 - <p class="album-stats"> 894 - {album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} • 895 - {album.total_plays} {album.total_plays === 1 ? 'play' : 'plays'} 896 - </p> 897 - </div> 898 - <div class="album-actions"> 899 - <button 900 - class="action-btn edit-cover-btn" 901 - onclick={() => startEditingAlbum(album.id)} 902 - title="edit cover art" 903 - > 904 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 905 - <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 906 - <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 907 - </svg> 908 - </button> 909 </div> 910 {/if} 911 - </div> 912 {/each} 913 </div> 914 {/if} ··· 1736 display: flex; 1737 flex-direction: column; 1738 gap: 0.75rem; 1739 } 1740 1741 .album-card:hover { 1742 - border-color: var(--border-emphasis); 1743 transform: translateY(-2px); 1744 } 1745 1746 - .album-card.editing { 1747 - border-color: var(--accent); 1748 - } 1749 - 1750 - .album-cover-container { 1751 width: 100%; 1752 aspect-ratio: 1; 1753 border-radius: 6px; 1754 - overflow: hidden; 1755 - background: var(--bg-primary); 1756 - border: 1px solid var(--border-subtle); 1757 - } 1758 - 1759 - .album-cover { 1760 - width: 100%; 1761 - height: 100%; 1762 object-fit: cover; 1763 } 1764 1765 .album-cover-placeholder { 1766 width: 100%; 1767 - height: 100%; 1768 display: flex; 1769 - flex-direction: column; 1770 align-items: center; 1771 justify-content: center; 1772 - color: var(--text-muted); 1773 - gap: 0.5rem; 1774 - } 1775 - 1776 - .album-cover-placeholder .file-name { 1777 - font-size: 0.85rem; 1778 - color: var(--text-tertiary); 1779 - text-align: center; 1780 - word-break: break-word; 1781 - padding: 0 0.5rem; 1782 - } 1783 - 1784 - .album-cover-placeholder .file-size { 1785 - font-size: 0.75rem; 1786 - color: var(--text-muted); 1787 } 1788 1789 .album-info { 1790 flex: 1; 1791 } 1792 ··· 1804 font-size: 0.85rem; 1805 color: var(--text-tertiary); 1806 margin: 0; 1807 - } 1808 - 1809 - .album-actions { 1810 - display: flex; 1811 - gap: 0.5rem; 1812 - justify-content: flex-end; 1813 - } 1814 - 1815 - .edit-cover-btn { 1816 - padding: 0.5rem; 1817 - background: var(--border-subtle); 1818 - border: 1px solid var(--border-emphasis); 1819 - border-radius: 4px; 1820 - cursor: pointer; 1821 - transition: all 0.2s; 1822 - display: flex; 1823 - align-items: center; 1824 - justify-content: center; 1825 - } 1826 - 1827 - .edit-cover-btn:hover { 1828 - background: var(--border-emphasis); 1829 - border-color: var(--accent); 1830 - color: var(--accent); 1831 - } 1832 - 1833 - .album-edit-container { 1834 - display: flex; 1835 - flex-direction: column; 1836 - gap: 1rem; 1837 - } 1838 - 1839 - .album-edit-preview { 1840 - width: 100%; 1841 - aspect-ratio: 1; 1842 - border-radius: 6px; 1843 - overflow: hidden; 1844 - background: var(--bg-primary); 1845 - border: 1px solid var(--border-subtle); 1846 - } 1847 - 1848 - .album-edit-actions { 1849 - display: flex; 1850 - flex-direction: column; 1851 - gap: 0.75rem; 1852 - } 1853 - 1854 - .file-input-label { 1855 - font-size: 0.85rem; 1856 - color: var(--text-secondary); 1857 - font-weight: 500; 1858 - margin-bottom: 0.25rem; 1859 - } 1860 - 1861 - .album-edit-actions .file-input { 1862 - padding: 0.5rem; 1863 - background: var(--border-subtle); 1864 - border: 1px solid var(--border-emphasis); 1865 - border-radius: 4px; 1866 - color: var(--text-primary); 1867 - font-size: 0.85rem; 1868 - cursor: pointer; 1869 - } 1870 - 1871 - .album-edit-actions .file-input:hover { 1872 - background: var(--border-emphasis); 1873 - border-color: var(--accent); 1874 - } 1875 - 1876 - .edit-buttons { 1877 - display: flex; 1878 - gap: 0.5rem; 1879 - justify-content: flex-end; 1880 } 1881 1882 /* playlists section */
··· 40 // album management state 41 let albums = $state<AlbumSummary[]>([]); 42 let loadingAlbums = $state(false); 43 44 // playlist management state 45 let playlists = $state<Playlist[]>([]); ··· 182 } finally { 183 loadingPlaylists = false; 184 } 185 } 186 187 async function saveProfile(e: SubmitEvent) { ··· 779 {:else} 780 <div class="albums-grid"> 781 {#each albums as album} 782 + <a href="/u/{auth.user?.handle}/album/{album.slug}" class="album-card"> 783 + {#if album.image_url} 784 + <img src={album.image_url} alt="{album.title} cover" class="album-cover" /> 785 {:else} 786 + <div class="album-cover-placeholder"> 787 + <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 788 + <rect x="3" y="3" width="18" height="18" stroke="currentColor" stroke-width="1.5" fill="none"/> 789 + <circle cx="12" cy="12" r="4" fill="currentColor"/> 790 + </svg> 791 </div> 792 {/if} 793 + <div class="album-info"> 794 + <h3 class="album-title">{album.title}</h3> 795 + <p class="album-stats"> 796 + {album.track_count} {album.track_count === 1 ? 'track' : 'tracks'} • 797 + {album.total_plays} {album.total_plays === 1 ? 'play' : 'plays'} 798 + </p> 799 + </div> 800 + </a> 801 {/each} 802 </div> 803 {/if} ··· 1625 display: flex; 1626 flex-direction: column; 1627 gap: 0.75rem; 1628 + text-decoration: none; 1629 + color: inherit; 1630 } 1631 1632 .album-card:hover { 1633 + border-color: var(--accent); 1634 transform: translateY(-2px); 1635 } 1636 1637 + .album-cover { 1638 width: 100%; 1639 aspect-ratio: 1; 1640 border-radius: 6px; 1641 object-fit: cover; 1642 } 1643 1644 .album-cover-placeholder { 1645 width: 100%; 1646 + aspect-ratio: 1; 1647 + border-radius: 6px; 1648 + background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 1649 display: flex; 1650 align-items: center; 1651 justify-content: center; 1652 + color: var(--accent); 1653 } 1654 1655 .album-info { 1656 + min-width: 0; 1657 flex: 1; 1658 } 1659 ··· 1671 font-size: 0.85rem; 1672 color: var(--text-tertiary); 1673 margin: 0; 1674 } 1675 1676 /* playlists section */
+622 -121
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 16 17 let { data }: { data: PageData } = $props(); 18 19 - const album = $derived(data.album); 20 const isAuthenticated = $derived(auth.isAuthenticated); 21 22 // check if current user owns this album 23 - const isOwner = $derived(auth.user?.did === album.metadata.artist_did); 24 // can only reorder if owner and album has an ATProto list 25 - const canReorder = $derived(isOwner && !!album.metadata.list_uri); 26 27 // local mutable copy of tracks for reordering 28 let tracks = $state<Track[]>([...data.album.tracks]); 29 - 30 - // sync when data changes (e.g., navigation) 31 - $effect(() => { 32 - tracks = [...data.album.tracks]; 33 - }); 34 35 // edit mode state 36 let isEditMode = $state(false); 37 let isSaving = $state(false); 38 39 // drag state 40 let draggedIndex = $state<number | null>(null); ··· 60 if (tracks.length > 0) { 61 queue.setQueue(tracks); 62 queue.playNow(tracks[0]); 63 - toast.success(`playing ${album.metadata.title}`, 1800); 64 } 65 } 66 67 function addToQueue() { 68 if (tracks.length > 0) { 69 queue.addTracks(tracks); 70 - toast.success(`added ${album.metadata.title} to queue`, 1800); 71 } 72 } 73 74 function toggleEditMode() { 75 if (isEditMode) { 76 - saveOrder(); 77 } 78 isEditMode = !isEditMode; 79 } 80 81 async function saveOrder() { 82 - if (!album.metadata.list_uri) return; 83 84 // extract rkey from list URI (at://did/collection/rkey) 85 - const rkey = album.metadata.list_uri.split('/').pop(); 86 if (!rkey) return; 87 88 // build strongRefs from current track order ··· 210 211 $effect(() => { 212 if (typeof window !== 'undefined') { 213 - shareUrl = `${window.location.origin}/u/${album.metadata.artist_handle}/album/${album.metadata.slug}`; 214 } 215 }); 216 </script> 217 218 <svelte:head> 219 - <title>{album.metadata.title} by {album.metadata.artist} - plyr.fm</title> 220 - <meta name="description" content="{album.metadata.title} by {album.metadata.artist} - {album.metadata.track_count} tracks on plyr.fm" /> 221 222 <!-- Open Graph / Facebook --> 223 <meta property="og:type" content="music.album" /> 224 - <meta property="og:title" content="{album.metadata.title} by {album.metadata.artist}" /> 225 - <meta property="og:description" content="{album.metadata.track_count} tracks • {album.metadata.total_plays} plays" /> 226 - <meta property="og:url" content="{APP_CANONICAL_URL}/u/{album.metadata.artist_handle}/album/{album.metadata.slug}" /> 227 <meta property="og:site_name" content={APP_NAME} /> 228 - <meta property="music:musician" content="{album.metadata.artist_handle}" /> 229 - {#if album.metadata.image_url && !isImageSensitiveSSR(album.metadata.image_url)} 230 - <meta property="og:image" content="{album.metadata.image_url}" /> 231 - <meta property="og:image:secure_url" content="{album.metadata.image_url}" /> 232 <meta property="og:image:width" content="1200" /> 233 <meta property="og:image:height" content="1200" /> 234 - <meta property="og:image:alt" content="{album.metadata.title} by {album.metadata.artist}" /> 235 {/if} 236 237 <!-- Twitter --> 238 <meta name="twitter:card" content="summary" /> 239 - <meta name="twitter:title" content="{album.metadata.title} by {album.metadata.artist}" /> 240 - <meta name="twitter:description" content="{album.metadata.track_count} tracks • {album.metadata.total_plays} plays" /> 241 - {#if album.metadata.image_url && !isImageSensitiveSSR(album.metadata.image_url)} 242 - <meta name="twitter:image" content="{album.metadata.image_url}" /> 243 {/if} 244 </svelte:head> 245 246 <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={() => goto('/login')} /> 247 <div class="container"> 248 <main> 249 - <div class="album-hero"> 250 - {#if album.metadata.image_url} 251 - <SensitiveImage src={album.metadata.image_url} tooltipPosition="center"> 252 - <img src={album.metadata.image_url} alt="{album.metadata.title} artwork" class="album-art" /> 253 </SensitiveImage> 254 {:else} 255 <div class="album-art-placeholder"> ··· 262 <div class="album-info-wrapper"> 263 <div class="album-info"> 264 <p class="album-type">album</p> 265 - <h1 class="album-title">{album.metadata.title}</h1> 266 <div class="album-meta"> 267 - <a href="/u/{album.metadata.artist_handle}" class="artist-link"> 268 - {album.metadata.artist} 269 </a> 270 <span class="meta-separator">•</span> 271 - <span>{album.metadata.track_count} {album.metadata.track_count === 1 ? 'track' : 'tracks'}</span> 272 <span class="meta-separator">•</span> 273 - <span>{album.metadata.total_plays} {album.metadata.total_plays === 1 ? 'play' : 'plays'}</span> 274 </div> 275 </div> 276 277 - <div class="side-button-right"> 278 <ShareButton url={shareUrl} title="share album" /> 279 </div> 280 </div> 281 </div> ··· 297 </svg> 298 add to queue 299 </button> 300 - {#if canReorder} 301 - <button 302 - class="reorder-button" 303 - class:active={isEditMode} 304 - onclick={toggleEditMode} 305 - disabled={isSaving} 306 - title={isEditMode ? 'save order' : 'reorder tracks'} 307 - > 308 - {#if isEditMode} 309 - {#if isSaving} 310 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 311 - <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 312 - </svg> 313 - saving... 314 {:else} 315 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 316 - <polyline points="20 6 9 17 4 12"></polyline> 317 </svg> 318 - done 319 {/if} 320 - {:else} 321 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 322 - <line x1="3" y1="12" x2="21" y2="12"></line> 323 - <line x1="3" y1="6" x2="21" y2="6"></line> 324 - <line x1="3" y1="18" x2="21" y2="18"></line> 325 </svg> 326 - reorder 327 - {/if} 328 - </button> 329 - {/if} 330 - <div class="mobile-share-button"> 331 - <ShareButton url={shareUrl} title="share album" /> 332 </div> 333 </div> 334 ··· 356 ondrop={(e) => handleDrop(e, i)} 357 ondragend={handleDragEnd} 358 > 359 - <button 360 - class="drag-handle" 361 - ontouchstart={(e) => handleTouchStart(e, i)} 362 - onclick={(e) => e.stopPropagation()} 363 - aria-label="drag to reorder" 364 - title="drag to reorder" 365 - > 366 - <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> 367 - <circle cx="5" cy="3" r="1.5"></circle> 368 - <circle cx="11" cy="3" r="1.5"></circle> 369 - <circle cx="5" cy="8" r="1.5"></circle> 370 - <circle cx="11" cy="8" r="1.5"></circle> 371 - <circle cx="5" cy="13" r="1.5"></circle> 372 - <circle cx="11" cy="13" r="1.5"></circle> 373 - </svg> 374 - </button> 375 <div class="track-content"> 376 <TrackItem 377 {track} ··· 384 hideArtist={true} 385 /> 386 </div> 387 </div> 388 {:else} 389 <TrackItem ··· 403 </main> 404 </div> 405 406 <style> 407 .container { 408 max-width: 1200px; ··· 456 gap: 0.5rem; 457 } 458 459 - .side-button-right { 460 flex-shrink: 0; 461 display: flex; 462 align-items: center; 463 - justify-content: center; 464 padding-bottom: 0.5rem; 465 } 466 467 - .mobile-share-button { 468 display: none; 469 } 470 471 .album-type { 472 text-transform: uppercase; 473 font-size: 0.75rem; ··· 553 color: var(--accent); 554 } 555 556 - .reorder-button { 557 - padding: 0.75rem 1.5rem; 558 - border-radius: 24px; 559 - font-weight: 600; 560 - font-size: 0.95rem; 561 - font-family: inherit; 562 - cursor: pointer; 563 - transition: all 0.2s; 564 - display: flex; 565 - align-items: center; 566 - gap: 0.5rem; 567 - background: transparent; 568 - color: var(--text-primary); 569 - border: 1px solid var(--border-default); 570 - } 571 - 572 - .reorder-button:hover { 573 - border-color: var(--accent); 574 - color: var(--accent); 575 - } 576 - 577 - .reorder-button:disabled { 578 - opacity: 0.6; 579 - cursor: not-allowed; 580 - } 581 - 582 - .reorder-button.active { 583 - border-color: var(--accent); 584 - color: var(--accent); 585 - background: color-mix(in srgb, var(--accent) 10%, transparent); 586 - } 587 - 588 .spinner { 589 animation: spin 1s linear infinite; 590 } ··· 699 width: 100%; 700 } 701 702 - .side-button-right { 703 display: none; 704 } 705 706 - .mobile-share-button { 707 display: flex; 708 - width: 100%; 709 - justify-content: center; 710 } 711 712 .album-title { ··· 724 } 725 726 .play-button, 727 - .queue-button, 728 - .reorder-button { 729 width: 100%; 730 justify-content: center; 731 } ··· 750 font-size: 0.8rem; 751 flex-wrap: wrap; 752 } 753 } 754 </style>
··· 16 17 let { data }: { data: PageData } = $props(); 18 19 + // local mutable album metadata for editing 20 + let albumMetadata = $state({ ...data.album.metadata }); 21 const isAuthenticated = $derived(auth.isAuthenticated); 22 23 + // sync when data changes (e.g., navigation) 24 + $effect(() => { 25 + albumMetadata = { ...data.album.metadata }; 26 + tracks = [...data.album.tracks]; 27 + }); 28 + 29 // check if current user owns this album 30 + const isOwner = $derived(auth.user?.did === albumMetadata.artist_did); 31 // can only reorder if owner and album has an ATProto list 32 + const canReorder = $derived(isOwner && !!albumMetadata.list_uri); 33 34 // local mutable copy of tracks for reordering 35 let tracks = $state<Track[]>([...data.album.tracks]); 36 37 // edit mode state 38 let isEditMode = $state(false); 39 let isSaving = $state(false); 40 + let editTitle = $state(''); 41 + 42 + // delete confirmation modal 43 + let showDeleteConfirm = $state(false); 44 + let deleting = $state(false); 45 + 46 + // cover upload 47 + let coverInputElement = $state<HTMLInputElement | null>(null); 48 + let uploadingCover = $state(false); 49 + 50 + // track removal 51 + let removingTrackId = $state<number | null>(null); 52 53 // drag state 54 let draggedIndex = $state<number | null>(null); ··· 74 if (tracks.length > 0) { 75 queue.setQueue(tracks); 76 queue.playNow(tracks[0]); 77 + toast.success(`playing ${albumMetadata.title}`, 1800); 78 } 79 } 80 81 function addToQueue() { 82 if (tracks.length > 0) { 83 queue.addTracks(tracks); 84 + toast.success(`added ${albumMetadata.title} to queue`, 1800); 85 } 86 } 87 88 function toggleEditMode() { 89 if (isEditMode) { 90 + // exiting edit mode - save changes 91 + saveAllChanges(); 92 + } else { 93 + // entering edit mode - initialize edit state 94 + editTitle = albumMetadata.title; 95 } 96 isEditMode = !isEditMode; 97 } 98 99 + async function saveAllChanges() { 100 + // save track order if album has ATProto list 101 + if (canReorder) { 102 + await saveOrder(); 103 + } 104 + 105 + // save title if changed 106 + if (editTitle.trim() && editTitle.trim() !== albumMetadata.title) { 107 + await saveTitleChange(); 108 + } 109 + } 110 + 111 + async function saveTitleChange() { 112 + if (!editTitle.trim() || editTitle.trim() === albumMetadata.title) return; 113 + 114 + try { 115 + const response = await fetch( 116 + `${API_URL}/albums/${albumMetadata.id}?title=${encodeURIComponent(editTitle.trim())}`, 117 + { 118 + method: 'PATCH', 119 + credentials: 'include' 120 + } 121 + ); 122 + 123 + if (!response.ok) { 124 + throw new Error('failed to update title'); 125 + } 126 + 127 + const updated = await response.json(); 128 + albumMetadata.title = updated.title; 129 + toast.success('title updated'); 130 + } catch (e) { 131 + console.error('failed to save title:', e); 132 + toast.error(e instanceof Error ? e.message : 'failed to save title'); 133 + // revert to original title 134 + editTitle = albumMetadata.title; 135 + } 136 + } 137 + 138 + function handleCoverSelect(event: Event) { 139 + const input = event.target as HTMLInputElement; 140 + const file = input.files?.[0]; 141 + if (!file) return; 142 + 143 + if (!file.type.startsWith('image/')) { 144 + toast.error('please select an image file'); 145 + return; 146 + } 147 + 148 + if (file.size > 20 * 1024 * 1024) { 149 + toast.error('image must be under 20MB'); 150 + return; 151 + } 152 + 153 + uploadCover(file); 154 + } 155 + 156 + async function uploadCover(file: File) { 157 + uploadingCover = true; 158 + try { 159 + const formData = new FormData(); 160 + formData.append('image', file); 161 + 162 + const response = await fetch(`${API_URL}/albums/${albumMetadata.id}/cover`, { 163 + method: 'POST', 164 + credentials: 'include', 165 + body: formData 166 + }); 167 + 168 + if (!response.ok) { 169 + throw new Error('failed to upload cover'); 170 + } 171 + 172 + const result = await response.json(); 173 + albumMetadata.image_url = result.image_url; 174 + toast.success('cover updated'); 175 + } catch (e) { 176 + console.error('failed to upload cover:', e); 177 + toast.error(e instanceof Error ? e.message : 'failed to upload cover'); 178 + } finally { 179 + uploadingCover = false; 180 + } 181 + } 182 + 183 + async function removeTrack(track: Track) { 184 + removingTrackId = track.id; 185 + 186 + try { 187 + const response = await fetch(`${API_URL}/albums/${albumMetadata.id}/tracks/${track.id}`, { 188 + method: 'DELETE', 189 + credentials: 'include' 190 + }); 191 + 192 + if (!response.ok) { 193 + const data = await response.json(); 194 + throw new Error(data.detail || 'failed to remove track'); 195 + } 196 + 197 + tracks = tracks.filter((t) => t.id !== track.id); 198 + albumMetadata.track_count = tracks.length; 199 + 200 + toast.success(`removed "${track.title}" from album`); 201 + } catch (e) { 202 + console.error('failed to remove track:', e); 203 + toast.error(e instanceof Error ? e.message : 'failed to remove track'); 204 + } finally { 205 + removingTrackId = null; 206 + } 207 + } 208 + 209 + async function deleteAlbum() { 210 + deleting = true; 211 + 212 + try { 213 + const response = await fetch(`${API_URL}/albums/${albumMetadata.id}`, { 214 + method: 'DELETE', 215 + credentials: 'include' 216 + }); 217 + 218 + if (!response.ok) { 219 + throw new Error('failed to delete album'); 220 + } 221 + 222 + toast.success('album deleted'); 223 + goto(`/u/${albumMetadata.artist_handle}`); 224 + } catch (e) { 225 + console.error('failed to delete album:', e); 226 + toast.error(e instanceof Error ? e.message : 'failed to delete album'); 227 + deleting = false; 228 + showDeleteConfirm = false; 229 + } 230 + } 231 + 232 + function handleKeydown(event: KeyboardEvent) { 233 + if (event.key === 'Escape') { 234 + if (showDeleteConfirm) { 235 + showDeleteConfirm = false; 236 + } else if (isEditMode) { 237 + // revert title change and exit edit mode 238 + editTitle = albumMetadata.title; 239 + isEditMode = false; 240 + } 241 + } 242 + } 243 + 244 async function saveOrder() { 245 + if (!albumMetadata.list_uri) return; 246 247 // extract rkey from list URI (at://did/collection/rkey) 248 + const rkey = albumMetadata.list_uri.split('/').pop(); 249 if (!rkey) return; 250 251 // build strongRefs from current track order ··· 373 374 $effect(() => { 375 if (typeof window !== 'undefined') { 376 + shareUrl = `${window.location.origin}/u/${albumMetadata.artist_handle}/album/${albumMetadata.slug}`; 377 } 378 }); 379 </script> 380 381 + <svelte:window on:keydown={handleKeydown} /> 382 + 383 <svelte:head> 384 + <title>{albumMetadata.title} by {albumMetadata.artist} - plyr.fm</title> 385 + <meta name="description" content="{albumMetadata.title} by {albumMetadata.artist} - {albumMetadata.track_count} tracks on plyr.fm" /> 386 387 <!-- Open Graph / Facebook --> 388 <meta property="og:type" content="music.album" /> 389 + <meta property="og:title" content="{albumMetadata.title} by {albumMetadata.artist}" /> 390 + <meta property="og:description" content="{albumMetadata.track_count} tracks • {albumMetadata.total_plays} plays" /> 391 + <meta property="og:url" content="{APP_CANONICAL_URL}/u/{albumMetadata.artist_handle}/album/{albumMetadata.slug}" /> 392 <meta property="og:site_name" content={APP_NAME} /> 393 + <meta property="music:musician" content="{albumMetadata.artist_handle}" /> 394 + {#if albumMetadata.image_url && !isImageSensitiveSSR(albumMetadata.image_url)} 395 + <meta property="og:image" content="{albumMetadata.image_url}" /> 396 + <meta property="og:image:secure_url" content="{albumMetadata.image_url}" /> 397 <meta property="og:image:width" content="1200" /> 398 <meta property="og:image:height" content="1200" /> 399 + <meta property="og:image:alt" content="{albumMetadata.title} by {albumMetadata.artist}" /> 400 {/if} 401 402 <!-- Twitter --> 403 <meta name="twitter:card" content="summary" /> 404 + <meta name="twitter:title" content="{albumMetadata.title} by {albumMetadata.artist}" /> 405 + <meta name="twitter:description" content="{albumMetadata.track_count} tracks • {albumMetadata.total_plays} plays" /> 406 + {#if albumMetadata.image_url && !isImageSensitiveSSR(albumMetadata.image_url)} 407 + <meta name="twitter:image" content="{albumMetadata.image_url}" /> 408 {/if} 409 </svelte:head> 410 411 <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={() => goto('/login')} /> 412 <div class="container"> 413 <main> 414 + <!-- hidden file input for cover upload --> 415 + <input 416 + type="file" 417 + accept="image/jpeg,image/png,image/webp" 418 + bind:this={coverInputElement} 419 + onchange={handleCoverSelect} 420 + hidden 421 + /> 422 + 423 + <div class="album-hero" class:edit-mode={isEditMode && isOwner}> 424 + {#if isEditMode && isOwner} 425 + <button 426 + class="album-art-wrapper clickable" 427 + onclick={() => coverInputElement?.click()} 428 + type="button" 429 + aria-label="change cover image" 430 + disabled={uploadingCover} 431 + > 432 + {#if albumMetadata.image_url} 433 + <img src={albumMetadata.image_url} alt="{albumMetadata.title} artwork" class="album-art" /> 434 + {:else} 435 + <div class="album-art-placeholder"> 436 + <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 437 + <rect x="3" y="3" width="18" height="18" stroke="currentColor" stroke-width="1.5" fill="none"/> 438 + <circle cx="12" cy="12" r="4" fill="currentColor"/> 439 + </svg> 440 + </div> 441 + {/if} 442 + <div class="art-edit-overlay" class:uploading={uploadingCover}> 443 + {#if uploadingCover} 444 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 445 + <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 446 + </svg> 447 + <span>uploading...</span> 448 + {:else} 449 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 450 + <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> 451 + <circle cx="8.5" cy="8.5" r="1.5"></circle> 452 + <polyline points="21 15 16 10 5 21"></polyline> 453 + </svg> 454 + <span>change cover</span> 455 + {/if} 456 + </div> 457 + </button> 458 + {:else if albumMetadata.image_url} 459 + <SensitiveImage src={albumMetadata.image_url} tooltipPosition="center"> 460 + <img src={albumMetadata.image_url} alt="{albumMetadata.title} artwork" class="album-art" /> 461 </SensitiveImage> 462 {:else} 463 <div class="album-art-placeholder"> ··· 470 <div class="album-info-wrapper"> 471 <div class="album-info"> 472 <p class="album-type">album</p> 473 + {#if isEditMode && isOwner} 474 + <input 475 + type="text" 476 + class="album-title-input" 477 + bind:value={editTitle} 478 + placeholder="album title" 479 + /> 480 + {:else} 481 + <h1 class="album-title">{albumMetadata.title}</h1> 482 + {/if} 483 <div class="album-meta"> 484 + <a href="/u/{albumMetadata.artist_handle}" class="artist-link"> 485 + {albumMetadata.artist} 486 </a> 487 <span class="meta-separator">•</span> 488 + <span>{albumMetadata.track_count} {albumMetadata.track_count === 1 ? 'track' : 'tracks'}</span> 489 <span class="meta-separator">•</span> 490 + <span>{albumMetadata.total_plays} {albumMetadata.total_plays === 1 ? 'play' : 'plays'}</span> 491 </div> 492 </div> 493 494 + <div class="side-buttons"> 495 <ShareButton url={shareUrl} title="share album" /> 496 + {#if isOwner} 497 + <button 498 + class="icon-btn" 499 + class:active={isEditMode} 500 + onclick={toggleEditMode} 501 + aria-label={isEditMode ? 'done editing' : 'edit album'} 502 + title={isEditMode ? 'done editing' : 'edit album'} 503 + > 504 + {#if isEditMode} 505 + {#if isSaving} 506 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 507 + <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 508 + </svg> 509 + {:else} 510 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 511 + <polyline points="20 6 9 17 4 12"></polyline> 512 + </svg> 513 + {/if} 514 + {:else} 515 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 516 + <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 517 + <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 518 + </svg> 519 + {/if} 520 + </button> 521 + <button 522 + class="icon-btn danger" 523 + onclick={() => (showDeleteConfirm = true)} 524 + aria-label="delete album" 525 + title="delete album" 526 + > 527 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 528 + <polyline points="3 6 5 6 21 6"></polyline> 529 + <path d="m19 6-.867 12.142A2 2 0 0 1 16.138 20H7.862a2 2 0 0 1-1.995-1.858L5 6"></path> 530 + <path d="M10 11v6"></path> 531 + <path d="M14 11v6"></path> 532 + <path d="m9 6 .5-2h5l.5 2"></path> 533 + </svg> 534 + </button> 535 + {/if} 536 </div> 537 </div> 538 </div> ··· 554 </svg> 555 add to queue 556 </button> 557 + <div class="mobile-buttons"> 558 + <ShareButton url={shareUrl} title="share album" /> 559 + {#if isOwner} 560 + <button 561 + class="icon-btn" 562 + class:active={isEditMode} 563 + onclick={toggleEditMode} 564 + aria-label={isEditMode ? 'done editing' : 'edit album'} 565 + title={isEditMode ? 'done editing' : 'edit album'} 566 + > 567 + {#if isEditMode} 568 + {#if isSaving} 569 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinner"> 570 + <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 571 + </svg> 572 + {:else} 573 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 574 + <polyline points="20 6 9 17 4 12"></polyline> 575 + </svg> 576 + {/if} 577 {:else} 578 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 579 + <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path> 580 + <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path> 581 </svg> 582 {/if} 583 + </button> 584 + <button 585 + class="icon-btn danger" 586 + onclick={() => (showDeleteConfirm = true)} 587 + aria-label="delete album" 588 + title="delete album" 589 + > 590 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 591 + <polyline points="3 6 5 6 21 6"></polyline> 592 + <path d="m19 6-.867 12.142A2 2 0 0 1 16.138 20H7.862a2 2 0 0 1-1.995-1.858L5 6"></path> 593 + <path d="M10 11v6"></path> 594 + <path d="M14 11v6"></path> 595 + <path d="m9 6 .5-2h5l.5 2"></path> 596 </svg> 597 + </button> 598 + {/if} 599 </div> 600 </div> 601 ··· 623 ondrop={(e) => handleDrop(e, i)} 624 ondragend={handleDragEnd} 625 > 626 + {#if canReorder} 627 + <button 628 + class="drag-handle" 629 + ontouchstart={(e) => handleTouchStart(e, i)} 630 + onclick={(e) => e.stopPropagation()} 631 + aria-label="drag to reorder" 632 + title="drag to reorder" 633 + > 634 + <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> 635 + <circle cx="5" cy="3" r="1.5"></circle> 636 + <circle cx="11" cy="3" r="1.5"></circle> 637 + <circle cx="5" cy="8" r="1.5"></circle> 638 + <circle cx="11" cy="8" r="1.5"></circle> 639 + <circle cx="5" cy="13" r="1.5"></circle> 640 + <circle cx="11" cy="13" r="1.5"></circle> 641 + </svg> 642 + </button> 643 + {/if} 644 <div class="track-content"> 645 <TrackItem 646 {track} ··· 653 hideArtist={true} 654 /> 655 </div> 656 + <button 657 + class="remove-track-btn" 658 + onclick={(e) => { 659 + e.stopPropagation(); 660 + removeTrack(track); 661 + }} 662 + disabled={removingTrackId === track.id} 663 + aria-label="remove track from album" 664 + title="remove track" 665 + > 666 + {#if removingTrackId === track.id} 667 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="spinner"> 668 + <circle cx="12" cy="12" r="10" stroke-dasharray="31.4" stroke-dashoffset="10"></circle> 669 + </svg> 670 + {:else} 671 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 672 + <line x1="18" y1="6" x2="6" y2="18"></line> 673 + <line x1="6" y1="6" x2="18" y2="18"></line> 674 + </svg> 675 + {/if} 676 + </button> 677 </div> 678 {:else} 679 <TrackItem ··· 693 </main> 694 </div> 695 696 + {#if showDeleteConfirm} 697 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 698 + <div 699 + class="modal-overlay" 700 + role="presentation" 701 + onclick={() => (showDeleteConfirm = false)} 702 + > 703 + <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> 704 + <div 705 + class="modal" 706 + role="alertdialog" 707 + aria-modal="true" 708 + aria-labelledby="delete-confirm-title" 709 + tabindex="-1" 710 + onclick={(e) => e.stopPropagation()} 711 + > 712 + <div class="modal-header"> 713 + <h3 id="delete-confirm-title">delete album?</h3> 714 + </div> 715 + <div class="modal-body"> 716 + <p> 717 + are you sure you want to delete "{albumMetadata.title}"? the tracks will remain as standalone tracks. 718 + </p> 719 + </div> 720 + <div class="modal-footer"> 721 + <button 722 + class="cancel-btn" 723 + onclick={() => (showDeleteConfirm = false)} 724 + disabled={deleting} 725 + > 726 + cancel 727 + </button> 728 + <button 729 + class="confirm-btn danger" 730 + onclick={deleteAlbum} 731 + disabled={deleting} 732 + > 733 + {deleting ? 'deleting...' : 'delete'} 734 + </button> 735 + </div> 736 + </div> 737 + </div> 738 + {/if} 739 + 740 <style> 741 .container { 742 max-width: 1200px; ··· 790 gap: 0.5rem; 791 } 792 793 + .side-buttons { 794 flex-shrink: 0; 795 display: flex; 796 align-items: center; 797 + gap: 0.5rem; 798 padding-bottom: 0.5rem; 799 } 800 801 + .mobile-buttons { 802 display: none; 803 } 804 805 + .icon-btn { 806 + display: flex; 807 + align-items: center; 808 + justify-content: center; 809 + width: 32px; 810 + height: 32px; 811 + background: transparent; 812 + border: 1px solid var(--border-default); 813 + border-radius: 4px; 814 + color: var(--text-tertiary); 815 + cursor: pointer; 816 + transition: all 0.15s; 817 + } 818 + 819 + .icon-btn:hover { 820 + border-color: var(--accent); 821 + color: var(--accent); 822 + } 823 + 824 + .icon-btn.danger:hover { 825 + border-color: var(--error); 826 + color: var(--error); 827 + } 828 + 829 + .icon-btn.active { 830 + border-color: var(--accent); 831 + color: var(--accent); 832 + background: color-mix(in srgb, var(--accent) 10%, transparent); 833 + } 834 + 835 + .album-art-wrapper { 836 + position: relative; 837 + border: none; 838 + padding: 0; 839 + background: none; 840 + } 841 + 842 + .album-art-wrapper.clickable { 843 + cursor: pointer; 844 + } 845 + 846 + .album-art-wrapper.clickable:hover .art-edit-overlay { 847 + opacity: 1; 848 + } 849 + 850 + .art-edit-overlay { 851 + position: absolute; 852 + inset: 0; 853 + background: rgba(0, 0, 0, 0.6); 854 + border-radius: 8px; 855 + display: flex; 856 + flex-direction: column; 857 + align-items: center; 858 + justify-content: center; 859 + gap: 0.5rem; 860 + color: white; 861 + font-size: 0.85rem; 862 + opacity: 0; 863 + transition: opacity 0.2s; 864 + } 865 + 866 + .art-edit-overlay.uploading { 867 + opacity: 1; 868 + } 869 + 870 + .album-title-input { 871 + font-size: 2.5rem; 872 + font-weight: 700; 873 + background: transparent; 874 + border: none; 875 + border-bottom: 2px solid var(--accent); 876 + color: var(--text-primary); 877 + padding: 0.25rem 0; 878 + width: 100%; 879 + outline: none; 880 + font-family: inherit; 881 + } 882 + 883 + .album-title-input::placeholder { 884 + color: var(--text-muted); 885 + } 886 + 887 .album-type { 888 text-transform: uppercase; 889 font-size: 0.75rem; ··· 969 color: var(--accent); 970 } 971 972 .spinner { 973 animation: spin 1s linear infinite; 974 } ··· 1083 width: 100%; 1084 } 1085 1086 + .side-buttons { 1087 display: none; 1088 } 1089 1090 + .mobile-buttons { 1091 display: flex; 1092 + gap: 0.5rem; 1093 } 1094 1095 .album-title { ··· 1107 } 1108 1109 .play-button, 1110 + .queue-button { 1111 width: 100%; 1112 justify-content: center; 1113 } ··· 1132 font-size: 0.8rem; 1133 flex-wrap: wrap; 1134 } 1135 + } 1136 + 1137 + /* remove track button */ 1138 + .remove-track-btn { 1139 + display: flex; 1140 + align-items: center; 1141 + justify-content: center; 1142 + width: 32px; 1143 + height: 32px; 1144 + border-radius: 50%; 1145 + background: transparent; 1146 + border: none; 1147 + color: var(--text-muted); 1148 + cursor: pointer; 1149 + transition: all 0.2s; 1150 + flex-shrink: 0; 1151 + } 1152 + 1153 + .remove-track-btn:hover { 1154 + color: var(--error); 1155 + background: color-mix(in srgb, var(--error) 10%, transparent); 1156 + } 1157 + 1158 + .remove-track-btn:disabled { 1159 + cursor: not-allowed; 1160 + opacity: 0.5; 1161 + } 1162 + 1163 + /* modal styles */ 1164 + .modal-overlay { 1165 + position: fixed; 1166 + inset: 0; 1167 + background: rgba(0, 0, 0, 0.7); 1168 + display: flex; 1169 + align-items: center; 1170 + justify-content: center; 1171 + z-index: 1000; 1172 + backdrop-filter: blur(4px); 1173 + } 1174 + 1175 + .modal { 1176 + background: var(--bg-secondary); 1177 + border-radius: 12px; 1178 + padding: 1.5rem; 1179 + max-width: 400px; 1180 + width: calc(100% - 2rem); 1181 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); 1182 + border: 1px solid var(--border-subtle); 1183 + } 1184 + 1185 + .modal-header { 1186 + margin-bottom: 1rem; 1187 + } 1188 + 1189 + .modal-header h3 { 1190 + margin: 0; 1191 + font-size: 1.25rem; 1192 + font-weight: 600; 1193 + color: var(--text-primary); 1194 + } 1195 + 1196 + .modal-body { 1197 + margin-bottom: 1.5rem; 1198 + } 1199 + 1200 + .modal-body p { 1201 + margin: 0; 1202 + color: var(--text-secondary); 1203 + line-height: 1.5; 1204 + } 1205 + 1206 + .modal-footer { 1207 + display: flex; 1208 + gap: 0.75rem; 1209 + justify-content: flex-end; 1210 + } 1211 + 1212 + .cancel-btn, 1213 + .confirm-btn { 1214 + padding: 0.625rem 1.25rem; 1215 + border-radius: 8px; 1216 + font-weight: 500; 1217 + font-size: 0.9rem; 1218 + font-family: inherit; 1219 + cursor: pointer; 1220 + transition: all 0.2s; 1221 + border: none; 1222 + } 1223 + 1224 + .cancel-btn { 1225 + background: var(--bg-tertiary); 1226 + color: var(--text-primary); 1227 + } 1228 + 1229 + .cancel-btn:hover { 1230 + background: var(--bg-hover); 1231 + } 1232 + 1233 + .cancel-btn:disabled { 1234 + opacity: 0.5; 1235 + cursor: not-allowed; 1236 + } 1237 + 1238 + .confirm-btn { 1239 + background: var(--accent); 1240 + color: var(--bg-primary); 1241 + } 1242 + 1243 + .confirm-btn:hover { 1244 + filter: brightness(1.1); 1245 + } 1246 + 1247 + .confirm-btn.danger { 1248 + background: var(--error); 1249 + } 1250 + 1251 + .confirm-btn:disabled { 1252 + opacity: 0.5; 1253 + cursor: not-allowed; 1254 } 1255 </style>