Compare changes

Choose any two refs to compare.

Changed files
+3394 -811
.claude
commands
.github
backend
docs
frontend
moderation
redis
scripts
+66
.claude/commands/digest.md
···
··· 1 + --- 2 + description: Extract actionable insights from an external resource 3 + argument-hint: [URL, AT-URI, or description of resource] 4 + --- 5 + 6 + # digest 7 + 8 + break down an external resource and identify what it means for us. 9 + 10 + ## process 11 + 12 + 1. **fetch the resource**: $ARGUMENTS 13 + - use pdsx MCP for AT-URIs (bsky posts, leaflet docs, etc.) 14 + - use WebFetch for URLs 15 + - ask for clarification if the resource type is unclear 16 + 17 + 2. **extract concrete information**: 18 + - what are they doing? 19 + - what's their architecture/approach? 20 + - what are their constraints and priorities? 21 + - what's their roadmap? 22 + 23 + 3. **cross-reference with our state**: 24 + - check open issues for overlap or gaps 25 + - grep codebase for related implementations 26 + - identify where we align or diverge 27 + 28 + 4. **identify actionable implications** - the core output: 29 + - "given that X is true, we should consider Y" 30 + - specific issues to open or update 31 + - code changes to consider 32 + - integration opportunities 33 + - things we're missing or doing wrong 34 + 35 + 5. **present findings** - be direct: 36 + - lead with the implications, not the summary 37 + - include specific file:line or issue references 38 + - propose concrete next steps 39 + 40 + ## anti-patterns 41 + 42 + - philosophical musing without action items 43 + - "we're complementary" without specifics 44 + - agreeing that everything is fine 45 + - backpedaling when challenged 46 + 47 + ## output format 48 + 49 + ``` 50 + ## implications 51 + 52 + 1. **[actionable item]**: [reasoning] 53 + - related: `file.py:123` or issue #456 54 + - suggested: [specific change or issue to create] 55 + 56 + 2. **[actionable item]**: ... 57 + 58 + ## extracted facts 59 + 60 + - [concrete thing from the resource] 61 + - [another concrete thing] 62 + 63 + ## open questions 64 + 65 + - [things to clarify or investigate further] 66 + ```
+47 -3
.github/workflows/check-rust.yml
··· 11 contents: read 12 13 jobs: 14 check: 15 name: cargo check 16 runs-on: ubuntu-latest 17 timeout-minutes: 15 18 19 strategy: 20 matrix: 21 - service: [moderation, transcoder] 22 23 steps: 24 - uses: actions/checkout@v4 25 26 - name: install rust toolchain 27 uses: dtolnay/rust-toolchain@stable 28 29 - name: cache cargo 30 uses: Swatinem/rust-cache@v2 31 with: 32 workspaces: ${{ matrix.service }} 33 34 - name: cargo check 35 working-directory: ${{ matrix.service }} 36 run: cargo check --release 37 38 docker-build: 39 name: docker build 40 runs-on: ubuntu-latest 41 timeout-minutes: 10 42 - needs: check 43 44 strategy: 45 matrix: 46 - service: [moderation, transcoder] 47 48 steps: 49 - uses: actions/checkout@v4 50 51 - name: build docker image 52 working-directory: ${{ matrix.service }} 53 run: docker build -t ${{ matrix.service }}:ci-test .
··· 11 contents: read 12 13 jobs: 14 + changes: 15 + name: detect changes 16 + runs-on: ubuntu-latest 17 + outputs: 18 + moderation: ${{ steps.filter.outputs.moderation }} 19 + transcoder: ${{ steps.filter.outputs.transcoder }} 20 + steps: 21 + - uses: actions/checkout@v4 22 + - uses: dorny/paths-filter@v3 23 + id: filter 24 + with: 25 + filters: | 26 + moderation: 27 + - 'moderation/**' 28 + - '.github/workflows/check-rust.yml' 29 + transcoder: 30 + - 'transcoder/**' 31 + - '.github/workflows/check-rust.yml' 32 + 33 check: 34 name: cargo check 35 runs-on: ubuntu-latest 36 timeout-minutes: 15 37 + needs: changes 38 39 strategy: 40 + fail-fast: false 41 matrix: 42 + include: 43 + - service: moderation 44 + changed: ${{ needs.changes.outputs.moderation }} 45 + - service: transcoder 46 + changed: ${{ needs.changes.outputs.transcoder }} 47 48 steps: 49 - uses: actions/checkout@v4 50 + if: matrix.changed == 'true' 51 52 - name: install rust toolchain 53 + if: matrix.changed == 'true' 54 uses: dtolnay/rust-toolchain@stable 55 56 - name: cache cargo 57 + if: matrix.changed == 'true' 58 uses: Swatinem/rust-cache@v2 59 with: 60 workspaces: ${{ matrix.service }} 61 62 - name: cargo check 63 + if: matrix.changed == 'true' 64 working-directory: ${{ matrix.service }} 65 run: cargo check --release 66 67 + - name: skip (no changes) 68 + if: matrix.changed != 'true' 69 + run: echo "skipping ${{ matrix.service }} - no changes" 70 + 71 docker-build: 72 name: docker build 73 runs-on: ubuntu-latest 74 timeout-minutes: 10 75 + needs: [changes, check] 76 77 strategy: 78 + fail-fast: false 79 matrix: 80 + include: 81 + - service: moderation 82 + changed: ${{ needs.changes.outputs.moderation }} 83 + - service: transcoder 84 + changed: ${{ needs.changes.outputs.transcoder }} 85 86 steps: 87 - uses: actions/checkout@v4 88 + if: matrix.changed == 'true' 89 90 - name: build docker image 91 + if: matrix.changed == 'true' 92 working-directory: ${{ matrix.service }} 93 run: docker build -t ${{ matrix.service }}:ci-test . 94 + 95 + - name: skip (no changes) 96 + if: matrix.changed != 'true' 97 + run: echo "skipping ${{ matrix.service }} - no changes"
+43
.github/workflows/deploy-redis.yml
···
··· 1 + name: deploy redis 2 + 3 + on: 4 + push: 5 + branches: 6 + - main 7 + paths: 8 + - "redis/fly.toml" 9 + - "redis/fly.staging.toml" 10 + - ".github/workflows/deploy-redis.yml" 11 + workflow_dispatch: 12 + 13 + jobs: 14 + deploy-staging: 15 + name: deploy redis staging 16 + runs-on: ubuntu-latest 17 + concurrency: deploy-redis-staging 18 + steps: 19 + - uses: actions/checkout@v4 20 + 21 + - uses: superfly/flyctl-actions/setup-flyctl@master 22 + 23 + - name: deploy to fly.io staging 24 + run: flyctl deploy --config redis/fly.staging.toml --remote-only -a plyr-redis-stg 25 + working-directory: redis 26 + env: 27 + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN_REDIS }} 28 + 29 + deploy-prod: 30 + name: deploy redis prod 31 + runs-on: ubuntu-latest 32 + needs: deploy-staging 33 + concurrency: deploy-redis-prod 34 + steps: 35 + - uses: actions/checkout@v4 36 + 37 + - uses: superfly/flyctl-actions/setup-flyctl@master 38 + 39 + - name: deploy to fly.io prod 40 + run: flyctl deploy --config redis/fly.toml --remote-only -a plyr-redis 41 + working-directory: redis 42 + env: 43 + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN_REDIS }}
+64
.github/workflows/run-moderation-loop.yml
···
··· 1 + # run moderation loop via workflow dispatch 2 + # 3 + # analyzes pending copyright flags, auto-resolves false positives, 4 + # creates review batches for human review, sends DM notification 5 + # 6 + # required secrets: 7 + # MODERATION_SERVICE_URL - moderation service base URL 8 + # MODERATION_AUTH_TOKEN - X-Moderation-Key header value 9 + # ANTHROPIC_API_KEY - for flag analysis 10 + # NOTIFY_BOT_HANDLE - bluesky bot handle for DMs 11 + # NOTIFY_BOT_PASSWORD - bluesky bot app password 12 + # NOTIFY_RECIPIENT_HANDLE - who receives DM notifications 13 + 14 + name: run moderation loop 15 + 16 + on: 17 + workflow_dispatch: 18 + inputs: 19 + dry_run: 20 + description: "dry run (analyze only, don't resolve or send DMs)" 21 + type: boolean 22 + default: true 23 + limit: 24 + description: "max flags to process (leave empty for all)" 25 + type: string 26 + default: "" 27 + env: 28 + description: "environment (for DM header)" 29 + type: choice 30 + options: 31 + - prod 32 + - staging 33 + - dev 34 + default: prod 35 + 36 + jobs: 37 + run: 38 + runs-on: ubuntu-latest 39 + 40 + steps: 41 + - uses: actions/checkout@v4 42 + 43 + - uses: astral-sh/setup-uv@v4 44 + 45 + - name: Run moderation loop 46 + run: | 47 + ARGS="" 48 + if [ "${{ inputs.dry_run }}" = "true" ]; then 49 + ARGS="$ARGS --dry-run" 50 + fi 51 + if [ -n "${{ inputs.limit }}" ]; then 52 + ARGS="$ARGS --limit ${{ inputs.limit }}" 53 + fi 54 + ARGS="$ARGS --env ${{ inputs.env }}" 55 + 56 + echo "Running: uv run scripts/moderation_loop.py $ARGS" 57 + uv run scripts/moderation_loop.py $ARGS 58 + env: 59 + MODERATION_SERVICE_URL: ${{ secrets.MODERATION_SERVICE_URL }} 60 + MODERATION_AUTH_TOKEN: ${{ secrets.MODERATION_AUTH_TOKEN }} 61 + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} 62 + NOTIFY_BOT_HANDLE: ${{ secrets.NOTIFY_BOT_HANDLE }} 63 + NOTIFY_BOT_PASSWORD: ${{ secrets.NOTIFY_BOT_PASSWORD }} 64 + NOTIFY_RECIPIENT_HANDLE: ${{ secrets.NOTIFY_RECIPIENT_HANDLE }}
+7 -5
README.md
··· 119 │ └── src/routes/ # pages 120 ├── moderation/ # Rust labeler service 121 ├── transcoder/ # Rust audio service 122 ├── docs/ # documentation 123 └── justfile # task runner 124 ``` ··· 128 <details> 129 <summary>costs</summary> 130 131 - ~$35-40/month: 132 - - fly.io backend (prod + staging): ~$10/month 133 - - fly.io transcoder: ~$0-5/month (auto-scales to zero) 134 - neon postgres: $5/month 135 - - audd audio fingerprinting: ~$10/month 136 - - cloudflare (pages + r2): ~$0.16/month 137 138 </details> 139
··· 119 │ └── src/routes/ # pages 120 ├── moderation/ # Rust labeler service 121 ├── transcoder/ # Rust audio service 122 + ├── redis/ # self-hosted Redis config 123 ├── docs/ # documentation 124 └── justfile # task runner 125 ``` ··· 129 <details> 130 <summary>costs</summary> 131 132 + ~$20/month: 133 + - fly.io (backend + redis + moderation): ~$14/month 134 - neon postgres: $5/month 135 + - cloudflare (pages + r2): ~$1/month 136 + - audd audio fingerprinting: $5-10/month (usage-based) 137 + 138 + live dashboard: https://plyr.fm/costs 139 140 </details> 141
+27 -11
STATUS.md
··· 47 48 ### December 2025 49 50 #### supporter-gated content (PR #637, Dec 22-23) 51 52 **atprotofans paywall integration** - artists can now mark tracks as "supporters only": ··· 168 169 ## immediate priorities 170 171 - ### end-of-year sprint (Dec 20-31) 172 173 - see [sprint tracking issue #625](https://github.com/zzstoatzz/plyr.fm/issues/625) for details. 174 175 - | track | focus | status | 176 - |-------|-------|--------| 177 - | moderation | consolidate architecture, add rules engine | planning | 178 - | atprotofans | supporter validation, content gating | shipped | 179 180 ### known issues 181 - playback auto-start on refresh (#225) ··· 262 263 ## cost structure 264 265 - current monthly costs: ~$18/month (plyr.fm specific) 266 267 see live dashboard: [plyr.fm/costs](https://plyr.fm/costs) 268 269 - - fly.io (plyr apps only): ~$12/month 270 - neon postgres: $5/month 271 - - cloudflare (R2 + pages + domain): ~$1.16/month 272 - - audd audio fingerprinting: $0-10/month (6000 free/month) 273 - logfire: $0 (free tier) 274 275 ## admin tooling ··· 320 │ └── src/routes/ # pages 321 ├── moderation/ # Rust moderation service (ATProto labeler) 322 ├── transcoder/ # Rust audio transcoding service 323 ├── docs/ # documentation 324 └── justfile # task runner 325 ``` ··· 335 336 --- 337 338 - this is a living document. last updated 2025-12-23.
··· 47 48 ### December 2025 49 50 + #### self-hosted redis (PR #674-675, Dec 30) 51 + 52 + **replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month → ~$4/month: 53 + - Upstash pay-as-you-go was charging per command (37M commands = $75) 54 + - self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment 55 + - deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging) 56 + - added CI workflow for redis deployments on merge 57 + 58 + **no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres. 59 + 60 + --- 61 + 62 #### supporter-gated content (PR #637, Dec 22-23) 63 64 **atprotofans paywall integration** - artists can now mark tracks as "supporters only": ··· 180 181 ## immediate priorities 182 183 + ### quality of life mode (Dec 29-31) 184 185 + end-of-year sprint [#625](https://github.com/zzstoatzz/plyr.fm/issues/625) complete. remaining days before 2026 are for minor polish and bug fixes as they arise. 186 187 + **what shipped in the sprint:** 188 + - moderation consolidation: sensitive images moved to moderation service (#644) 189 + - atprotofans: supporter badges (#627) and content gating (#637) 190 + 191 + **aspirational (deferred until scale justifies):** 192 + - configurable rules engine for moderation 193 + - time-release gating (#642) 194 195 ### known issues 196 - playback auto-start on refresh (#225) ··· 277 278 ## cost structure 279 280 + current monthly costs: ~$20/month (plyr.fm specific) 281 282 see live dashboard: [plyr.fm/costs](https://plyr.fm/costs) 283 284 + - fly.io (backend + redis + moderation): ~$14/month 285 - neon postgres: $5/month 286 + - cloudflare (R2 + pages + domain): ~$1/month 287 + - audd audio fingerprinting: $5-10/month (usage-based) 288 - logfire: $0 (free tier) 289 290 ## admin tooling ··· 335 │ └── src/routes/ # pages 336 ├── moderation/ # Rust moderation service (ATProto labeler) 337 ├── transcoder/ # Rust audio transcoding service 338 + ├── redis/ # self-hosted Redis config 339 ├── docs/ # documentation 340 └── justfile # task runner 341 ``` ··· 351 352 --- 353 354 + this is a living document. last updated 2025-12-30.
+1 -1
backend/fly.staging.toml
··· 44 # - AWS_ACCESS_KEY_ID (cloudflare R2) 45 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 46 # - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') 47 - # - DOCKET_URL (upstash redis: rediss://default:xxx@xxx.upstash.io:6379)
··· 44 # - AWS_ACCESS_KEY_ID (cloudflare R2) 45 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 46 # - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') 47 + # - DOCKET_URL (self-hosted redis: redis://plyr-redis-stg.internal:6379)
+1 -1
backend/fly.toml
··· 39 # - AWS_ACCESS_KEY_ID (cloudflare R2) 40 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 41 # - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') 42 - # - DOCKET_URL (upstash redis: rediss://default:xxx@xxx.upstash.io:6379)
··· 39 # - AWS_ACCESS_KEY_ID (cloudflare R2) 40 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 41 # - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') 42 + # - DOCKET_URL (self-hosted redis: redis://plyr-redis.internal:6379)
+2
backend/src/backend/_internal/background.py
··· 55 extra={"docket_name": settings.docket.name, "url": settings.docket.url}, 56 ) 57 58 async with Docket( 59 name=settings.docket.name, 60 url=settings.docket.url,
··· 55 extra={"docket_name": settings.docket.name, "url": settings.docket.url}, 56 ) 57 58 + # WARNING: do not modify Docket() or Worker() constructor args without 59 + # reading docs/backend/background-tasks.md - see 2025-12-30 incident 60 async with Docket( 61 name=settings.docket.name, 62 url=settings.docket.url,
+31
backend/src/backend/_internal/moderation_client.py
··· 35 error: str | None = None 36 37 38 class ModerationClient: 39 """client for the plyr.fm moderation service. 40 ··· 152 except Exception as e: 153 logger.warning("failed to emit copyright label for %s: %s", uri, e) 154 return EmitLabelResult(success=False, error=str(e)) 155 156 async def get_active_labels(self, uris: list[str]) -> set[str]: 157 """check which URIs have active (non-negated) copyright-violation labels.
··· 35 error: str | None = None 36 37 38 + @dataclass 39 + class SensitiveImagesResult: 40 + """result from fetching sensitive images.""" 41 + 42 + image_ids: list[str] 43 + urls: list[str] 44 + 45 + 46 class ModerationClient: 47 """client for the plyr.fm moderation service. 48 ··· 160 except Exception as e: 161 logger.warning("failed to emit copyright label for %s: %s", uri, e) 162 return EmitLabelResult(success=False, error=str(e)) 163 + 164 + async def get_sensitive_images(self) -> SensitiveImagesResult: 165 + """fetch all sensitive images from the moderation service. 166 + 167 + returns: 168 + SensitiveImagesResult with image_ids and urls 169 + 170 + raises: 171 + httpx.HTTPStatusError: on non-2xx response 172 + httpx.TimeoutException: on timeout 173 + """ 174 + async with httpx.AsyncClient(timeout=self.timeout) as client: 175 + response = await client.get( 176 + f"{self.labeler_url}/sensitive-images", 177 + # no auth required for this public endpoint 178 + ) 179 + response.raise_for_status() 180 + data = response.json() 181 + 182 + return SensitiveImagesResult( 183 + image_ids=data.get("image_ids", []), 184 + urls=data.get("urls", []), 185 + ) 186 187 async def get_active_labels(self, uris: list[str]) -> set[str]: 188 """check which URIs have active (non-negated) copyright-violation labels.
+14 -12
backend/src/backend/api/moderation.py
··· 1 """content moderation api endpoints.""" 2 3 - from typing import Annotated 4 5 - from fastapi import APIRouter, Depends, Request 6 from pydantic import BaseModel 7 - from sqlalchemy import select 8 - from sqlalchemy.ext.asyncio import AsyncSession 9 10 - from backend.models import SensitiveImage, get_db 11 from backend.utilities.rate_limit import limiter 12 13 router = APIRouter(prefix="/moderation", tags=["moderation"]) 14 ··· 26 @limiter.limit("10/minute") 27 async def get_sensitive_images( 28 request: Request, 29 - db: Annotated[AsyncSession, Depends(get_db)], 30 ) -> SensitiveImagesResponse: 31 """get all flagged sensitive images. 32 33 returns both image_ids (for R2-stored images) and full URLs 34 (for external images like avatars). clients should check both. 35 """ 36 - result = await db.execute(select(SensitiveImage)) 37 - images = result.scalars().all() 38 39 - image_ids = [img.image_id for img in images if img.image_id] 40 - urls = [img.url for img in images if img.url] 41 - 42 - return SensitiveImagesResponse(image_ids=image_ids, urls=urls)
··· 1 """content moderation api endpoints.""" 2 3 + import logging 4 5 + from fastapi import APIRouter, Request 6 from pydantic import BaseModel 7 8 + from backend._internal.moderation_client import get_moderation_client 9 from backend.utilities.rate_limit import limiter 10 + 11 + logger = logging.getLogger(__name__) 12 13 router = APIRouter(prefix="/moderation", tags=["moderation"]) 14 ··· 26 @limiter.limit("10/minute") 27 async def get_sensitive_images( 28 request: Request, 29 ) -> SensitiveImagesResponse: 30 """get all flagged sensitive images. 31 32 + proxies to the moderation service which is the source of truth 33 + for sensitive image data. 34 + 35 returns both image_ids (for R2-stored images) and full URLs 36 (for external images like avatars). clients should check both. 37 """ 38 + client = get_moderation_client() 39 + result = await client.get_sensitive_images() 40 41 + return SensitiveImagesResponse( 42 + image_ids=result.image_ids, 43 + urls=result.urls, 44 + )
+83 -1
backend/tests/test_moderation.py
··· 4 5 import httpx 6 import pytest 7 from sqlalchemy import select 8 from sqlalchemy.ext.asyncio import AsyncSession 9 ··· 11 get_active_copyright_labels, 12 scan_track_for_copyright, 13 ) 14 - from backend._internal.moderation_client import ModerationClient, ScanResult 15 from backend.models import Artist, CopyrightScan, Track 16 17 ··· 519 520 # scan2 should still be flagged 521 assert scan2.is_flagged is True
··· 4 5 import httpx 6 import pytest 7 + from fastapi.testclient import TestClient 8 from sqlalchemy import select 9 from sqlalchemy.ext.asyncio import AsyncSession 10 ··· 12 get_active_copyright_labels, 13 scan_track_for_copyright, 14 ) 15 + from backend._internal.moderation_client import ( 16 + ModerationClient, 17 + ScanResult, 18 + SensitiveImagesResult, 19 + ) 20 from backend.models import Artist, CopyrightScan, Track 21 22 ··· 524 525 # scan2 should still be flagged 526 assert scan2.is_flagged is True 527 + 528 + 529 + # tests for sensitive images 530 + 531 + 532 + async def test_moderation_client_get_sensitive_images() -> None: 533 + """test ModerationClient.get_sensitive_images() with successful response.""" 534 + mock_response = Mock() 535 + mock_response.json.return_value = { 536 + "image_ids": ["abc123", "def456"], 537 + "urls": ["https://example.com/image.jpg"], 538 + } 539 + mock_response.raise_for_status.return_value = None 540 + 541 + client = ModerationClient( 542 + service_url="https://test.example.com", 543 + labeler_url="https://labeler.example.com", 544 + auth_token="test-token", 545 + timeout_seconds=30, 546 + label_cache_prefix="test:label:", 547 + label_cache_ttl_seconds=300, 548 + ) 549 + 550 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: 551 + mock_get.return_value = mock_response 552 + 553 + result = await client.get_sensitive_images() 554 + 555 + assert result.image_ids == ["abc123", "def456"] 556 + assert result.urls == ["https://example.com/image.jpg"] 557 + mock_get.assert_called_once() 558 + 559 + 560 + async def test_moderation_client_get_sensitive_images_empty() -> None: 561 + """test ModerationClient.get_sensitive_images() with empty response.""" 562 + mock_response = Mock() 563 + mock_response.json.return_value = {"image_ids": [], "urls": []} 564 + mock_response.raise_for_status.return_value = None 565 + 566 + client = ModerationClient( 567 + service_url="https://test.example.com", 568 + labeler_url="https://labeler.example.com", 569 + auth_token="test-token", 570 + timeout_seconds=30, 571 + label_cache_prefix="test:label:", 572 + label_cache_ttl_seconds=300, 573 + ) 574 + 575 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: 576 + mock_get.return_value = mock_response 577 + 578 + result = await client.get_sensitive_images() 579 + 580 + assert result.image_ids == [] 581 + assert result.urls == [] 582 + 583 + 584 + async def test_get_sensitive_images_endpoint( 585 + client: TestClient, 586 + ) -> None: 587 + """test GET /moderation/sensitive-images endpoint proxies to moderation service.""" 588 + mock_result = SensitiveImagesResult( 589 + image_ids=["image1", "image2"], 590 + urls=["https://example.com/avatar.jpg"], 591 + ) 592 + 593 + with patch("backend.api.moderation.get_moderation_client") as mock_get_client: 594 + mock_client = AsyncMock() 595 + mock_client.get_sensitive_images.return_value = mock_result 596 + mock_get_client.return_value = mock_client 597 + 598 + response = client.get("/moderation/sensitive-images") 599 + 600 + assert response.status_code == 200 601 + data = response.json() 602 + assert data["image_ids"] == ["image1", "image2"] 603 + assert data["urls"] == ["https://example.com/avatar.jpg"]
+29 -17
docs/backend/background-tasks.md
··· 39 DOCKET_WORKER_CONCURRENCY=10 # concurrent task limit 40 ``` 41 42 when `DOCKET_URL` is not set, docket is disabled and tasks fall back to `asyncio.create_task()` (fire-and-forget). 43 44 ### local development ··· 54 55 ### production/staging 56 57 - Redis instances are provisioned via Upstash (managed Redis): 58 59 - | environment | instance | region | 60 - |-------------|----------|--------| 61 - | production | `plyr-redis-prd` | us-east-1 (near fly.io) | 62 - | staging | `plyr-redis-stg` | us-east-1 | 63 64 set `DOCKET_URL` in fly.io secrets: 65 ```bash 66 - flyctl secrets set DOCKET_URL=rediss://default:xxx@xxx.upstash.io:6379 -a relay-api 67 - flyctl secrets set DOCKET_URL=rediss://default:xxx@xxx.upstash.io:6379 -a relay-api-staging 68 ``` 69 70 - note: use `rediss://` (with double 's') for TLS connections to Upstash. 71 72 ## usage 73 ··· 117 118 ## costs 119 120 - **Upstash pricing** (pay-per-request): 121 - - free tier: 10k commands/day 122 - - pro: $0.2 per 100k commands + $0.25/GB storage 123 124 - for plyr.fm's volume (~100 uploads/day), this stays well within free tier or costs $0-5/mo. 125 - 126 - **tips to avoid surprise bills**: 127 - - use **regional** (not global) replication 128 - - set **max data limit** (256MB is plenty for a task queue) 129 - - monitor usage in Upstash dashboard 130 131 ## fallback behavior 132
··· 39 DOCKET_WORKER_CONCURRENCY=10 # concurrent task limit 40 ``` 41 42 + ### ⚠️ worker settings - do not modify 43 + 44 + the worker is initialized in `backend/_internal/background.py` with pydocket's defaults. **do not change these settings without extensive testing:** 45 + 46 + | setting | default | why it matters | 47 + |---------|---------|----------------| 48 + | `heartbeat_interval` | 2s | changing this broke all task execution (2025-12-30 incident) | 49 + | `minimum_check_interval` | 1s | affects how quickly tasks are picked up | 50 + | `scheduling_resolution` | 1s | affects scheduled task precision | 51 + 52 + **2025-12-30 incident**: setting `heartbeat_interval=30s` caused all scheduled tasks (likes, comments, exports) to silently fail while perpetual tasks continued running. root cause unclear - correlation was definitive but mechanism wasn't found in pydocket source. reverted in PR #669. 53 + 54 + if you need to tune worker settings: 55 + 1. test extensively in staging with real task volume 56 + 2. verify ALL task types execute (not just perpetual tasks) 57 + 3. check logfire for task execution spans 58 + 59 when `DOCKET_URL` is not set, docket is disabled and tasks fall back to `asyncio.create_task()` (fire-and-forget). 60 61 ### local development ··· 71 72 ### production/staging 73 74 + Redis instances are self-hosted on Fly.io (redis:7-alpine): 75 76 + | environment | fly app | region | 77 + |-------------|---------|--------| 78 + | production | `plyr-redis` | iad | 79 + | staging | `plyr-redis-stg` | iad | 80 81 set `DOCKET_URL` in fly.io secrets: 82 ```bash 83 + flyctl secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api 84 + flyctl secrets set DOCKET_URL=redis://plyr-redis-stg.internal:6379 -a relay-api-staging 85 ``` 86 87 + note: uses Fly internal networking (`.internal` domain), no TLS needed within private network. 88 89 ## usage 90 ··· 134 135 ## costs 136 137 + **self-hosted Redis on Fly.io** (fixed monthly): 138 + - ~$2/month per instance (256MB shared-cpu VM) 139 + - ~$4/month total for prod + staging 140 141 + this replaced Upstash pay-per-command pricing which was costing ~$75/month at scale (37M commands/month). 142 143 ## fallback behavior 144
+2 -2
docs/deployment/environments.md
··· 7 | environment | trigger | backend URL | database | redis | frontend | storage | 8 |-------------|---------|-------------|----------|-------|----------|---------| 9 | **development** | local | localhost:8001 | plyr-dev (neon) | localhost:6379 (docker) | localhost:5173 | audio-dev, images-dev (r2) | 10 - | **staging** | push to main | api-stg.plyr.fm | plyr-stg (neon) | plyr-redis-stg (upstash) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) | 11 - | **production** | github release | api.plyr.fm | plyr-prd (neon) | plyr-redis-prd (upstash) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) | 12 13 ## workflow 14
··· 7 | environment | trigger | backend URL | database | redis | frontend | storage | 8 |-------------|---------|-------------|----------|-------|----------|---------| 9 | **development** | local | localhost:8001 | plyr-dev (neon) | localhost:6379 (docker) | localhost:5173 | audio-dev, images-dev (r2) | 10 + | **staging** | push to main | api-stg.plyr.fm | plyr-stg (neon) | plyr-redis-stg (fly.io) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) | 11 + | **production** | github release | api.plyr.fm | plyr-prd (neon) | plyr-redis (fly.io) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) | 12 13 ## workflow 14
+117
docs/frontend/design-tokens.md
···
··· 1 + # design tokens 2 + 3 + CSS custom properties defined in `frontend/src/routes/+layout.svelte`. Use these instead of hardcoding values. 4 + 5 + ## border radius 6 + 7 + ```css 8 + --radius-sm: 4px; /* tight corners (inputs, small elements) */ 9 + --radius-base: 6px; /* default for most elements */ 10 + --radius-md: 8px; /* cards, modals */ 11 + --radius-lg: 12px; /* larger containers */ 12 + --radius-xl: 16px; /* prominent elements */ 13 + --radius-2xl: 24px; /* hero elements */ 14 + --radius-full: 9999px; /* pills, circles */ 15 + ``` 16 + 17 + ## typography 18 + 19 + ```css 20 + /* scale */ 21 + --text-xs: 0.75rem; /* 12px - hints, captions */ 22 + --text-sm: 0.85rem; /* 13.6px - labels, secondary */ 23 + --text-base: 0.9rem; /* 14.4px - body default */ 24 + --text-lg: 1rem; /* 16px - body emphasized */ 25 + --text-xl: 1.1rem; /* 17.6px - subheadings */ 26 + --text-2xl: 1.25rem; /* 20px - section headings */ 27 + --text-3xl: 1.5rem; /* 24px - page headings */ 28 + 29 + /* semantic aliases */ 30 + --text-page-heading: var(--text-3xl); 31 + --text-section-heading: 1.2rem; 32 + --text-body: var(--text-lg); 33 + --text-small: var(--text-base); 34 + ``` 35 + 36 + ## colors 37 + 38 + ### accent 39 + 40 + ```css 41 + --accent: #6a9fff; /* primary brand color (user-customizable) */ 42 + --accent-hover: #8ab3ff; /* hover state */ 43 + --accent-muted: #4a7ddd; /* subdued variant */ 44 + --accent-rgb: 106, 159, 255; /* for rgba() usage */ 45 + ``` 46 + 47 + ### backgrounds 48 + 49 + ```css 50 + /* dark theme */ 51 + --bg-primary: #0a0a0a; /* main background */ 52 + --bg-secondary: #141414; /* elevated surfaces */ 53 + --bg-tertiary: #1a1a1a; /* cards, modals */ 54 + --bg-hover: #1f1f1f; /* hover states */ 55 + 56 + /* light theme overrides these automatically */ 57 + ``` 58 + 59 + ### borders 60 + 61 + ```css 62 + --border-subtle: #282828; /* barely visible */ 63 + --border-default: #333333; /* standard borders */ 64 + --border-emphasis: #444444; /* highlighted borders */ 65 + ``` 66 + 67 + ### text 68 + 69 + ```css 70 + --text-primary; /* high contrast */ 71 + --text-secondary; /* medium contrast */ 72 + --text-tertiary; /* low contrast */ 73 + --text-muted; /* very low contrast */ 74 + ``` 75 + 76 + ### semantic 77 + 78 + ```css 79 + --success: #22c55e; 80 + --warning: #f59e0b; 81 + --error: #ef4444; 82 + ``` 83 + 84 + ## usage 85 + 86 + ```svelte 87 + <style> 88 + .card { 89 + border-radius: var(--radius-md); 90 + background: var(--bg-tertiary); 91 + border: 1px solid var(--border-default); 92 + } 93 + 94 + .label { 95 + font-size: var(--text-sm); 96 + color: var(--text-secondary); 97 + } 98 + 99 + input:focus { 100 + border-color: var(--accent); 101 + } 102 + </style> 103 + ``` 104 + 105 + ## anti-patterns 106 + 107 + ```css 108 + /* bad - hardcoded values */ 109 + border-radius: 8px; 110 + font-size: 14px; 111 + background: #1a1a1a; 112 + 113 + /* good - use tokens */ 114 + border-radius: var(--radius-md); 115 + font-size: var(--text-base); 116 + background: var(--bg-tertiary); 117 + ```
+53
docs/security.md
··· 24 * **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters. 25 * **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests. 26 27 ## CORS 28 29 Cross-Origin Resource Sharing (CORS) is configured to allow:
··· 24 * **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters. 25 * **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests. 26 27 + ## Supporter-Gated Content 28 + 29 + Tracks with `support_gate` set require atprotofans supporter validation before streaming. 30 + 31 + ### Access Model 32 + 33 + ``` 34 + request → /audio/{file_id} → check support_gate 35 + 36 + ┌──────────┴──────────┐ 37 + ↓ ↓ 38 + public gated track 39 + ↓ ↓ 40 + 307 → R2 CDN validate_supporter() 41 + 42 + ┌──────────┴──────────┐ 43 + ↓ ↓ 44 + is supporter not supporter 45 + ↓ ↓ 46 + presigned URL (5min) 402 error 47 + ``` 48 + 49 + ### Storage Architecture 50 + 51 + - **public bucket**: `plyr-audio` - CDN-backed, public read access 52 + - **private bucket**: `plyr-audio-private` - no public access, presigned URLs only 53 + 54 + when `support_gate` is toggled, a background task moves the file between buckets. 55 + 56 + ### Presigned URL Behavior 57 + 58 + presigned URLs are time-limited (5 minutes) and grant direct R2 access. security considerations: 59 + 60 + 1. **URL sharing**: a supporter could share the presigned URL. mitigation: short TTL, URLs expire quickly. 61 + 62 + 2. **offline caching**: if a supporter downloads content (via "download liked tracks"), the cached audio persists locally even if support lapses. this is **intentional** - they legitimately accessed it when authorized. 63 + 64 + 3. **auto-download + gated tracks**: the `gated` field is viewer-resolved (true = no access, false = has access). when liking a track with auto-download enabled: 65 + - **supporters** (`gated === false`): download proceeds normally via presigned URL 66 + - **non-supporters** (`gated === true`): download is skipped client-side to avoid wasted 402 requests 67 + 68 + ### ATProto Record Behavior 69 + 70 + when a track is gated, the ATProto `fm.plyr.track` record's `audioUrl` changes: 71 + - **public**: points to R2 CDN URL (e.g., `https://cdn.plyr.fm/audio/abc123.mp3`) 72 + - **gated**: points to API endpoint (e.g., `https://api.plyr.fm/audio/abc123`) 73 + 74 + this means ATProto clients cannot stream gated content without authentication through plyr.fm's API. 75 + 76 + ### Validation Caching 77 + 78 + currently, `validate_supporter()` makes a fresh call to atprotofans on every request. for high-traffic gated tracks, consider adding a short TTL cache (e.g., 60s in redis) to reduce latency and avoid rate limits. 79 + 80 ## CORS 81 82 Cross-Origin Resource Sharing (CORS) is configured to allow:
+1
frontend/CLAUDE.md
··· 6 - **state**: global managers in `lib/*.svelte.ts` using `$state` runes (player, queue, uploader, tracks cache) 7 - **components**: reusable ui in `lib/components/` (LikeButton, Toast, Player, etc) 8 - **routes**: pages in `routes/` with `+page.svelte` and `+page.ts` for data loading 9 10 gotchas: 11 - **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`)
··· 6 - **state**: global managers in `lib/*.svelte.ts` using `$state` runes (player, queue, uploader, tracks cache) 7 - **components**: reusable ui in `lib/components/` (LikeButton, Toast, Player, etc) 8 - **routes**: pages in `routes/` with `+page.svelte` and `+page.ts` for data loading 9 + - **design tokens**: use CSS variables from `+layout.svelte` - never hardcode colors, radii, or font sizes (see `docs/frontend/design-tokens.md`) 10 11 gotchas: 12 - **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`)
+29
frontend/src/lib/breakpoints.ts
···
··· 1 + /** 2 + * responsive breakpoints 3 + * 4 + * CSS media queries can't use CSS variables, so we document the values here 5 + * as the single source of truth. when changing breakpoints, update both this 6 + * file and the corresponding @media queries in components. 7 + * 8 + * usage in components: 9 + * @media (max-width: 768px) { ... } // mobile 10 + * @media (max-width: 1100px) { ... } // header mobile (needs margin space) 11 + */ 12 + 13 + /** standard mobile breakpoint - used by most components */ 14 + export const MOBILE_BREAKPOINT = 768; 15 + 16 + /** small mobile breakpoint - extra compact styles */ 17 + export const MOBILE_SMALL_BREAKPOINT = 480; 18 + 19 + /** 20 + * header mobile breakpoint - switch to mobile layout before margin elements 21 + * (stats, search, logout) crowd each other. 22 + */ 23 + export const HEADER_MOBILE_BREAKPOINT = 1300; 24 + 25 + /** content max-width used across pages */ 26 + export const CONTENT_MAX_WIDTH = 800; 27 + 28 + /** queue panel width */ 29 + export const QUEUE_WIDTH = 360;
+19 -17
frontend/src/lib/components/AddToMenu.svelte
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 initialLiked?: boolean; 14 disabled?: boolean; 15 disabledReason?: string; ··· 25 trackUri, 26 trackCid, 27 fileId, 28 initialLiked = false, 29 disabled = false, 30 disabledReason, ··· 102 103 try { 104 const success = liked 105 - ? await likeTrack(trackId, fileId) 106 : await unlikeTrack(trackId); 107 108 if (!success) { ··· 437 justify-content: center; 438 background: transparent; 439 border: 1px solid var(--border-default); 440 - border-radius: 4px; 441 color: var(--text-tertiary); 442 cursor: pointer; 443 transition: all 0.2s; ··· 488 min-width: 200px; 489 background: var(--bg-secondary); 490 border: 1px solid var(--border-default); 491 - border-radius: 8px; 492 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 493 overflow: hidden; 494 z-index: 10; ··· 508 background: transparent; 509 border: none; 510 color: var(--text-primary); 511 - font-size: 0.9rem; 512 font-family: inherit; 513 cursor: pointer; 514 transition: background 0.15s; ··· 542 border: none; 543 border-bottom: 1px solid var(--border-subtle); 544 color: var(--text-secondary); 545 - font-size: 0.85rem; 546 font-family: inherit; 547 cursor: pointer; 548 transition: background 0.15s; ··· 565 566 .playlist-list::-webkit-scrollbar-track { 567 background: transparent; 568 - border-radius: 4px; 569 } 570 571 .playlist-list::-webkit-scrollbar-thumb { 572 background: var(--border-default); 573 - border-radius: 4px; 574 } 575 576 .playlist-list::-webkit-scrollbar-thumb:hover { ··· 586 background: transparent; 587 border: none; 588 color: var(--text-primary); 589 - font-size: 0.9rem; 590 font-family: inherit; 591 cursor: pointer; 592 transition: background 0.15s; ··· 605 .playlist-thumb-placeholder { 606 width: 32px; 607 height: 32px; 608 - border-radius: 4px; 609 flex-shrink: 0; 610 } 611 ··· 637 gap: 0.5rem; 638 padding: 1.5rem 1rem; 639 color: var(--text-tertiary); 640 - font-size: 0.85rem; 641 } 642 643 .create-playlist-btn { ··· 650 border: none; 651 border-top: 1px solid var(--border-subtle); 652 color: var(--accent); 653 - font-size: 0.9rem; 654 font-family: inherit; 655 cursor: pointer; 656 transition: background 0.15s; ··· 673 padding: 0.625rem 0.75rem; 674 background: var(--bg-tertiary); 675 border: 1px solid var(--border-default); 676 - border-radius: 6px; 677 color: var(--text-primary); 678 font-family: inherit; 679 - font-size: 0.9rem; 680 } 681 682 .create-form input:focus { ··· 696 padding: 0.625rem 1rem; 697 background: var(--accent); 698 border: none; 699 - border-radius: 6px; 700 color: white; 701 font-family: inherit; 702 - font-size: 0.9rem; 703 font-weight: 500; 704 cursor: pointer; 705 transition: opacity 0.15s; ··· 719 height: 16px; 720 border: 2px solid var(--border-default); 721 border-top-color: var(--accent); 722 - border-radius: 50%; 723 animation: spin 0.8s linear infinite; 724 } 725 ··· 781 782 .menu-item { 783 padding: 1rem 1.25rem; 784 - font-size: 1rem; 785 } 786 787 .back-button {
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 + gated?: boolean; 14 initialLiked?: boolean; 15 disabled?: boolean; 16 disabledReason?: string; ··· 26 trackUri, 27 trackCid, 28 fileId, 29 + gated, 30 initialLiked = false, 31 disabled = false, 32 disabledReason, ··· 104 105 try { 106 const success = liked 107 + ? await likeTrack(trackId, fileId, gated) 108 : await unlikeTrack(trackId); 109 110 if (!success) { ··· 439 justify-content: center; 440 background: transparent; 441 border: 1px solid var(--border-default); 442 + border-radius: var(--radius-sm); 443 color: var(--text-tertiary); 444 cursor: pointer; 445 transition: all 0.2s; ··· 490 min-width: 200px; 491 background: var(--bg-secondary); 492 border: 1px solid var(--border-default); 493 + border-radius: var(--radius-md); 494 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 495 overflow: hidden; 496 z-index: 10; ··· 510 background: transparent; 511 border: none; 512 color: var(--text-primary); 513 + font-size: var(--text-base); 514 font-family: inherit; 515 cursor: pointer; 516 transition: background 0.15s; ··· 544 border: none; 545 border-bottom: 1px solid var(--border-subtle); 546 color: var(--text-secondary); 547 + font-size: var(--text-sm); 548 font-family: inherit; 549 cursor: pointer; 550 transition: background 0.15s; ··· 567 568 .playlist-list::-webkit-scrollbar-track { 569 background: transparent; 570 + border-radius: var(--radius-sm); 571 } 572 573 .playlist-list::-webkit-scrollbar-thumb { 574 background: var(--border-default); 575 + border-radius: var(--radius-sm); 576 } 577 578 .playlist-list::-webkit-scrollbar-thumb:hover { ··· 588 background: transparent; 589 border: none; 590 color: var(--text-primary); 591 + font-size: var(--text-base); 592 font-family: inherit; 593 cursor: pointer; 594 transition: background 0.15s; ··· 607 .playlist-thumb-placeholder { 608 width: 32px; 609 height: 32px; 610 + border-radius: var(--radius-sm); 611 flex-shrink: 0; 612 } 613 ··· 639 gap: 0.5rem; 640 padding: 1.5rem 1rem; 641 color: var(--text-tertiary); 642 + font-size: var(--text-sm); 643 } 644 645 .create-playlist-btn { ··· 652 border: none; 653 border-top: 1px solid var(--border-subtle); 654 color: var(--accent); 655 + font-size: var(--text-base); 656 font-family: inherit; 657 cursor: pointer; 658 transition: background 0.15s; ··· 675 padding: 0.625rem 0.75rem; 676 background: var(--bg-tertiary); 677 border: 1px solid var(--border-default); 678 + border-radius: var(--radius-base); 679 color: var(--text-primary); 680 font-family: inherit; 681 + font-size: var(--text-base); 682 } 683 684 .create-form input:focus { ··· 698 padding: 0.625rem 1rem; 699 background: var(--accent); 700 border: none; 701 + border-radius: var(--radius-base); 702 color: white; 703 font-family: inherit; 704 + font-size: var(--text-base); 705 font-weight: 500; 706 cursor: pointer; 707 transition: opacity 0.15s; ··· 721 height: 16px; 722 border: 2px solid var(--border-default); 723 border-top-color: var(--accent); 724 + border-radius: var(--radius-full); 725 animation: spin 0.8s linear infinite; 726 } 727 ··· 783 784 .menu-item { 785 padding: 1rem 1.25rem; 786 + font-size: var(--text-lg); 787 } 788 789 .back-button {
+7 -7
frontend/src/lib/components/AlbumSelect.svelte
··· 102 padding: 0.75rem; 103 background: var(--bg-primary); 104 border: 1px solid var(--border-default); 105 - border-radius: 4px; 106 color: var(--text-primary); 107 - font-size: 1rem; 108 font-family: inherit; 109 transition: all 0.2s; 110 } ··· 127 overflow-y: auto; 128 background: var(--bg-tertiary); 129 border: 1px solid var(--border-default); 130 - border-radius: 4px; 131 margin-top: 0.25rem; 132 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 133 } ··· 139 140 .album-results::-webkit-scrollbar-track { 141 background: var(--bg-primary); 142 - border-radius: 4px; 143 } 144 145 .album-results::-webkit-scrollbar-thumb { 146 background: var(--border-default); 147 - border-radius: 4px; 148 } 149 150 .album-results::-webkit-scrollbar-thumb:hover { ··· 203 } 204 205 .album-stats { 206 - font-size: 0.85rem; 207 color: var(--text-tertiary); 208 overflow: hidden; 209 text-overflow: ellipsis; ··· 212 213 .similar-hint { 214 margin-top: 0.5rem; 215 - font-size: 0.85rem; 216 color: var(--warning); 217 font-style: italic; 218 margin-bottom: 0;
··· 102 padding: 0.75rem; 103 background: var(--bg-primary); 104 border: 1px solid var(--border-default); 105 + border-radius: var(--radius-sm); 106 color: var(--text-primary); 107 + font-size: var(--text-lg); 108 font-family: inherit; 109 transition: all 0.2s; 110 } ··· 127 overflow-y: auto; 128 background: var(--bg-tertiary); 129 border: 1px solid var(--border-default); 130 + border-radius: var(--radius-sm); 131 margin-top: 0.25rem; 132 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 133 } ··· 139 140 .album-results::-webkit-scrollbar-track { 141 background: var(--bg-primary); 142 + border-radius: var(--radius-sm); 143 } 144 145 .album-results::-webkit-scrollbar-thumb { 146 background: var(--border-default); 147 + border-radius: var(--radius-sm); 148 } 149 150 .album-results::-webkit-scrollbar-thumb:hover { ··· 203 } 204 205 .album-stats { 206 + font-size: var(--text-sm); 207 color: var(--text-tertiary); 208 overflow: hidden; 209 text-overflow: ellipsis; ··· 212 213 .similar-hint { 214 margin-top: 0.5rem; 215 + font-size: var(--text-sm); 216 color: var(--warning); 217 font-style: italic; 218 margin-bottom: 0;
+15 -15
frontend/src/lib/components/BrokenTracks.svelte
··· 194 margin-bottom: 3rem; 195 background: color-mix(in srgb, var(--warning) 5%, transparent); 196 border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent); 197 - border-radius: 8px; 198 padding: 1.5rem; 199 } 200 ··· 213 } 214 215 .section-header h2 { 216 - font-size: 1.5rem; 217 margin: 0; 218 color: var(--warning); 219 } ··· 222 padding: 0.5rem 1rem; 223 background: color-mix(in srgb, var(--warning) 20%, transparent); 224 border: 1px solid color-mix(in srgb, var(--warning) 50%, transparent); 225 - border-radius: 4px; 226 color: var(--warning); 227 font-family: inherit; 228 - font-size: 0.9rem; 229 font-weight: 600; 230 cursor: pointer; 231 transition: all 0.2s; ··· 248 background: color-mix(in srgb, var(--warning) 20%, transparent); 249 color: var(--warning); 250 padding: 0.25rem 0.6rem; 251 - border-radius: 12px; 252 - font-size: 0.85rem; 253 font-weight: 600; 254 } 255 ··· 263 .broken-track-item { 264 background: var(--bg-tertiary); 265 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 266 - border-radius: 6px; 267 padding: 1rem; 268 display: flex; 269 align-items: center; ··· 280 } 281 282 .warning-icon { 283 - font-size: 1.25rem; 284 flex-shrink: 0; 285 } 286 ··· 291 292 .track-title { 293 font-weight: 600; 294 - font-size: 1rem; 295 margin-bottom: 0.25rem; 296 color: var(--text-primary); 297 } 298 299 .track-meta { 300 - font-size: 0.9rem; 301 color: var(--text-secondary); 302 margin-bottom: 0.5rem; 303 } 304 305 .issue-description { 306 - font-size: 0.85rem; 307 color: var(--warning); 308 } 309 ··· 311 padding: 0.5rem 1rem; 312 background: color-mix(in srgb, var(--warning) 15%, transparent); 313 border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent); 314 - border-radius: 4px; 315 color: var(--warning); 316 font-family: inherit; 317 - font-size: 0.9rem; 318 font-weight: 500; 319 cursor: pointer; 320 transition: all 0.2s; ··· 337 .info-box { 338 background: var(--bg-primary); 339 border: 1px solid var(--border-subtle); 340 - border-radius: 6px; 341 padding: 1rem; 342 - font-size: 0.9rem; 343 color: var(--text-secondary); 344 } 345
··· 194 margin-bottom: 3rem; 195 background: color-mix(in srgb, var(--warning) 5%, transparent); 196 border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent); 197 + border-radius: var(--radius-md); 198 padding: 1.5rem; 199 } 200 ··· 213 } 214 215 .section-header h2 { 216 + font-size: var(--text-3xl); 217 margin: 0; 218 color: var(--warning); 219 } ··· 222 padding: 0.5rem 1rem; 223 background: color-mix(in srgb, var(--warning) 20%, transparent); 224 border: 1px solid color-mix(in srgb, var(--warning) 50%, transparent); 225 + border-radius: var(--radius-sm); 226 color: var(--warning); 227 font-family: inherit; 228 + font-size: var(--text-base); 229 font-weight: 600; 230 cursor: pointer; 231 transition: all 0.2s; ··· 248 background: color-mix(in srgb, var(--warning) 20%, transparent); 249 color: var(--warning); 250 padding: 0.25rem 0.6rem; 251 + border-radius: var(--radius-lg); 252 + font-size: var(--text-sm); 253 font-weight: 600; 254 } 255 ··· 263 .broken-track-item { 264 background: var(--bg-tertiary); 265 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 266 + border-radius: var(--radius-base); 267 padding: 1rem; 268 display: flex; 269 align-items: center; ··· 280 } 281 282 .warning-icon { 283 + font-size: var(--text-2xl); 284 flex-shrink: 0; 285 } 286 ··· 291 292 .track-title { 293 font-weight: 600; 294 + font-size: var(--text-lg); 295 margin-bottom: 0.25rem; 296 color: var(--text-primary); 297 } 298 299 .track-meta { 300 + font-size: var(--text-base); 301 color: var(--text-secondary); 302 margin-bottom: 0.5rem; 303 } 304 305 .issue-description { 306 + font-size: var(--text-sm); 307 color: var(--warning); 308 } 309 ··· 311 padding: 0.5rem 1rem; 312 background: color-mix(in srgb, var(--warning) 15%, transparent); 313 border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent); 314 + border-radius: var(--radius-sm); 315 color: var(--warning); 316 font-family: inherit; 317 + font-size: var(--text-base); 318 font-weight: 500; 319 cursor: pointer; 320 transition: all 0.2s; ··· 337 .info-box { 338 background: var(--bg-primary); 339 border: 1px solid var(--border-subtle); 340 + border-radius: var(--radius-base); 341 padding: 1rem; 342 + font-size: var(--text-base); 343 color: var(--text-secondary); 344 } 345
+8 -8
frontend/src/lib/components/ColorSettings.svelte
··· 115 border: 1px solid var(--border-default); 116 color: var(--text-secondary); 117 padding: 0.5rem; 118 - border-radius: 4px; 119 cursor: pointer; 120 transition: all 0.2s; 121 display: flex; ··· 134 right: 0; 135 background: var(--bg-secondary); 136 border: 1px solid var(--border-default); 137 - border-radius: 6px; 138 padding: 1rem; 139 min-width: 240px; 140 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); ··· 147 align-items: center; 148 margin-bottom: 1rem; 149 color: var(--text-primary); 150 - font-size: 0.9rem; 151 } 152 153 .close-btn { 154 background: transparent; 155 border: none; 156 color: var(--text-secondary); 157 - font-size: 1.5rem; 158 cursor: pointer; 159 padding: 0; 160 width: 24px; ··· 182 width: 48px; 183 height: 32px; 184 border: 1px solid var(--border-default); 185 - border-radius: 4px; 186 cursor: pointer; 187 background: transparent; 188 } ··· 198 199 .color-value { 200 font-family: monospace; 201 - font-size: 0.85rem; 202 color: var(--text-secondary); 203 } 204 ··· 209 } 210 211 .presets-label { 212 - font-size: 0.85rem; 213 color: var(--text-tertiary); 214 } 215 ··· 222 .preset-btn { 223 width: 32px; 224 height: 32px; 225 - border-radius: 4px; 226 border: 2px solid transparent; 227 cursor: pointer; 228 transition: all 0.2s;
··· 115 border: 1px solid var(--border-default); 116 color: var(--text-secondary); 117 padding: 0.5rem; 118 + border-radius: var(--radius-sm); 119 cursor: pointer; 120 transition: all 0.2s; 121 display: flex; ··· 134 right: 0; 135 background: var(--bg-secondary); 136 border: 1px solid var(--border-default); 137 + border-radius: var(--radius-base); 138 padding: 1rem; 139 min-width: 240px; 140 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); ··· 147 align-items: center; 148 margin-bottom: 1rem; 149 color: var(--text-primary); 150 + font-size: var(--text-base); 151 } 152 153 .close-btn { 154 background: transparent; 155 border: none; 156 color: var(--text-secondary); 157 + font-size: var(--text-3xl); 158 cursor: pointer; 159 padding: 0; 160 width: 24px; ··· 182 width: 48px; 183 height: 32px; 184 border: 1px solid var(--border-default); 185 + border-radius: var(--radius-sm); 186 cursor: pointer; 187 background: transparent; 188 } ··· 198 199 .color-value { 200 font-family: monospace; 201 + font-size: var(--text-sm); 202 color: var(--text-secondary); 203 } 204 ··· 209 } 210 211 .presets-label { 212 + font-size: var(--text-sm); 213 color: var(--text-tertiary); 214 } 215 ··· 222 .preset-btn { 223 width: 32px; 224 height: 32px; 225 + border-radius: var(--radius-sm); 226 border: 2px solid transparent; 227 cursor: pointer; 228 transition: all 0.2s;
+9 -9
frontend/src/lib/components/HandleAutocomplete.svelte
··· 131 padding: 0.75rem; 132 background: var(--bg-primary); 133 border: 1px solid var(--border-default); 134 - border-radius: 4px; 135 color: var(--text-primary); 136 - font-size: 1rem; 137 font-family: inherit; 138 transition: border-color 0.2s; 139 box-sizing: border-box; ··· 159 top: 50%; 160 transform: translateY(-50%); 161 color: var(--text-muted); 162 - font-size: 0.85rem; 163 } 164 165 .results { ··· 170 overflow-y: auto; 171 background: var(--bg-tertiary); 172 border: 1px solid var(--border-default); 173 - border-radius: 4px; 174 margin-top: 0.25rem; 175 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 176 scrollbar-width: thin; ··· 183 184 .results::-webkit-scrollbar-track { 185 background: var(--bg-primary); 186 - border-radius: 4px; 187 } 188 189 .results::-webkit-scrollbar-thumb { 190 background: var(--border-default); 191 - border-radius: 4px; 192 } 193 194 .results::-webkit-scrollbar-thumb:hover { ··· 222 .avatar { 223 width: 36px; 224 height: 36px; 225 - border-radius: 50%; 226 object-fit: cover; 227 border: 2px solid var(--border-default); 228 flex-shrink: 0; ··· 231 .avatar-placeholder { 232 width: 36px; 233 height: 36px; 234 - border-radius: 50%; 235 background: var(--border-default); 236 flex-shrink: 0; 237 } ··· 252 } 253 254 .handle { 255 - font-size: 0.85rem; 256 color: var(--text-tertiary); 257 overflow: hidden; 258 text-overflow: ellipsis;
··· 131 padding: 0.75rem; 132 background: var(--bg-primary); 133 border: 1px solid var(--border-default); 134 + border-radius: var(--radius-sm); 135 color: var(--text-primary); 136 + font-size: var(--text-lg); 137 font-family: inherit; 138 transition: border-color 0.2s; 139 box-sizing: border-box; ··· 159 top: 50%; 160 transform: translateY(-50%); 161 color: var(--text-muted); 162 + font-size: var(--text-sm); 163 } 164 165 .results { ··· 170 overflow-y: auto; 171 background: var(--bg-tertiary); 172 border: 1px solid var(--border-default); 173 + border-radius: var(--radius-sm); 174 margin-top: 0.25rem; 175 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 176 scrollbar-width: thin; ··· 183 184 .results::-webkit-scrollbar-track { 185 background: var(--bg-primary); 186 + border-radius: var(--radius-sm); 187 } 188 189 .results::-webkit-scrollbar-thumb { 190 background: var(--border-default); 191 + border-radius: var(--radius-sm); 192 } 193 194 .results::-webkit-scrollbar-thumb:hover { ··· 222 .avatar { 223 width: 36px; 224 height: 36px; 225 + border-radius: var(--radius-full); 226 object-fit: cover; 227 border: 2px solid var(--border-default); 228 flex-shrink: 0; ··· 231 .avatar-placeholder { 232 width: 36px; 233 height: 36px; 234 + border-radius: var(--radius-full); 235 background: var(--border-default); 236 flex-shrink: 0; 237 } ··· 252 } 253 254 .handle { 255 + font-size: var(--text-sm); 256 color: var(--text-tertiary); 257 overflow: hidden; 258 text-overflow: ellipsis;
+15 -15
frontend/src/lib/components/HandleSearch.svelte
··· 179 padding: 0.75rem; 180 background: var(--bg-primary); 181 border: 1px solid var(--border-default); 182 - border-radius: 4px; 183 color: var(--text-primary); 184 - font-size: 1rem; 185 font-family: inherit; 186 transition: all 0.2s; 187 } ··· 201 right: 0.75rem; 202 top: 50%; 203 transform: translateY(-50%); 204 - font-size: 0.85rem; 205 color: var(--text-muted); 206 } 207 ··· 213 overflow-y: auto; 214 background: var(--bg-tertiary); 215 border: 1px solid var(--border-default); 216 - border-radius: 4px; 217 margin-top: 0.25rem; 218 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 219 } ··· 225 226 .search-results::-webkit-scrollbar-track { 227 background: var(--bg-primary); 228 - border-radius: 4px; 229 } 230 231 .search-results::-webkit-scrollbar-thumb { 232 background: var(--border-default); 233 - border-radius: 4px; 234 } 235 236 .search-results::-webkit-scrollbar-thumb:hover { ··· 277 .result-avatar { 278 width: 36px; 279 height: 36px; 280 - border-radius: 50%; 281 object-fit: cover; 282 border: 2px solid var(--border-default); 283 flex-shrink: 0; ··· 299 } 300 301 .result-handle { 302 - font-size: 0.85rem; 303 color: var(--text-tertiary); 304 overflow: hidden; 305 text-overflow: ellipsis; ··· 320 padding: 0.5rem 0.75rem; 321 background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); 322 border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle)); 323 - border-radius: 20px; 324 color: var(--text-primary); 325 - font-size: 0.9rem; 326 } 327 328 .chip-avatar { 329 width: 24px; 330 height: 24px; 331 - border-radius: 50%; 332 object-fit: cover; 333 border: 1px solid var(--border-default); 334 } ··· 365 366 .max-features-message { 367 margin-top: 0.5rem; 368 - font-size: 0.85rem; 369 color: var(--warning); 370 } 371 ··· 374 padding: 0.75rem; 375 background: color-mix(in srgb, var(--warning) 10%, var(--bg-primary)); 376 border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--border-subtle)); 377 - border-radius: 4px; 378 color: var(--warning); 379 - font-size: 0.9rem; 380 text-align: center; 381 } 382 ··· 401 402 .selected-artist-chip { 403 padding: 0.4rem 0.6rem; 404 - font-size: 0.85rem; 405 } 406 407 .chip-avatar {
··· 179 padding: 0.75rem; 180 background: var(--bg-primary); 181 border: 1px solid var(--border-default); 182 + border-radius: var(--radius-sm); 183 color: var(--text-primary); 184 + font-size: var(--text-lg); 185 font-family: inherit; 186 transition: all 0.2s; 187 } ··· 201 right: 0.75rem; 202 top: 50%; 203 transform: translateY(-50%); 204 + font-size: var(--text-sm); 205 color: var(--text-muted); 206 } 207 ··· 213 overflow-y: auto; 214 background: var(--bg-tertiary); 215 border: 1px solid var(--border-default); 216 + border-radius: var(--radius-sm); 217 margin-top: 0.25rem; 218 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 219 } ··· 225 226 .search-results::-webkit-scrollbar-track { 227 background: var(--bg-primary); 228 + border-radius: var(--radius-sm); 229 } 230 231 .search-results::-webkit-scrollbar-thumb { 232 background: var(--border-default); 233 + border-radius: var(--radius-sm); 234 } 235 236 .search-results::-webkit-scrollbar-thumb:hover { ··· 277 .result-avatar { 278 width: 36px; 279 height: 36px; 280 + border-radius: var(--radius-full); 281 object-fit: cover; 282 border: 2px solid var(--border-default); 283 flex-shrink: 0; ··· 299 } 300 301 .result-handle { 302 + font-size: var(--text-sm); 303 color: var(--text-tertiary); 304 overflow: hidden; 305 text-overflow: ellipsis; ··· 320 padding: 0.5rem 0.75rem; 321 background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); 322 border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle)); 323 + border-radius: var(--radius-xl); 324 color: var(--text-primary); 325 + font-size: var(--text-base); 326 } 327 328 .chip-avatar { 329 width: 24px; 330 height: 24px; 331 + border-radius: var(--radius-full); 332 object-fit: cover; 333 border: 1px solid var(--border-default); 334 } ··· 365 366 .max-features-message { 367 margin-top: 0.5rem; 368 + font-size: var(--text-sm); 369 color: var(--warning); 370 } 371 ··· 374 padding: 0.75rem; 375 background: color-mix(in srgb, var(--warning) 10%, var(--bg-primary)); 376 border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--border-subtle)); 377 + border-radius: var(--radius-sm); 378 color: var(--warning); 379 + font-size: var(--text-base); 380 text-align: center; 381 } 382 ··· 401 402 .selected-artist-chip { 403 padding: 0.4rem 0.6rem; 404 + font-size: var(--text-sm); 405 } 406 407 .chip-avatar {
+18 -18
frontend/src/lib/components/Header.svelte
··· 228 justify-content: center; 229 width: 44px; 230 height: 44px; 231 - border-radius: 10px; 232 background: transparent; 233 border: none; 234 color: var(--text-secondary); ··· 275 border: 1px solid var(--border-emphasis); 276 color: var(--text-secondary); 277 padding: 0.5rem 1rem; 278 - border-radius: 6px; 279 - font-size: 0.9rem; 280 font-family: inherit; 281 cursor: pointer; 282 transition: all 0.2s; ··· 309 } 310 311 .tangled-icon { 312 - border-radius: 4px; 313 opacity: 0.7; 314 transition: opacity 0.2s, box-shadow 0.2s; 315 } ··· 320 } 321 322 h1 { 323 - font-size: 1.5rem; 324 margin: 0; 325 color: var(--text-primary); 326 transition: color 0.2s; ··· 353 .nav-link { 354 color: var(--text-secondary); 355 text-decoration: none; 356 - font-size: 0.9rem; 357 transition: all 0.2s; 358 white-space: nowrap; 359 display: flex; 360 align-items: center; 361 gap: 0.4rem; 362 padding: 0.4rem 0.75rem; 363 - border-radius: 6px; 364 border: 1px solid transparent; 365 } 366 ··· 388 .user-handle { 389 color: var(--text-secondary); 390 text-decoration: none; 391 - font-size: 0.9rem; 392 padding: 0.4rem 0.75rem; 393 background: var(--bg-tertiary); 394 - border-radius: 6px; 395 border: 1px solid var(--border-default); 396 transition: all 0.2s; 397 white-space: nowrap; ··· 408 border: 1px solid var(--accent); 409 color: var(--accent); 410 padding: 0.5rem 1rem; 411 - border-radius: 6px; 412 - font-size: 0.9rem; 413 text-decoration: none; 414 transition: all 0.2s; 415 cursor: pointer; ··· 421 color: var(--bg-primary); 422 } 423 424 - /* Hide margin-positioned elements and switch to mobile layout at the same breakpoint. 425 - Account for queue panel (320px) potentially being open - need extra headroom */ 426 - @media (max-width: 1599px) { 427 .margin-left, 428 .logout-right { 429 display: none !important; ··· 442 } 443 } 444 445 - /* Smaller screens: compact header */ 446 @media (max-width: 768px) { 447 .header-content { 448 padding: 0.75rem 0.75rem; ··· 467 468 .nav-link { 469 padding: 0.3rem 0.5rem; 470 - font-size: 0.8rem; 471 } 472 473 .nav-link span { ··· 475 } 476 477 .user-handle { 478 - font-size: 0.8rem; 479 padding: 0.3rem 0.5rem; 480 } 481 482 .btn-primary { 483 - font-size: 0.8rem; 484 padding: 0.3rem 0.65rem; 485 } 486 }
··· 228 justify-content: center; 229 width: 44px; 230 height: 44px; 231 + border-radius: var(--radius-md); 232 background: transparent; 233 border: none; 234 color: var(--text-secondary); ··· 275 border: 1px solid var(--border-emphasis); 276 color: var(--text-secondary); 277 padding: 0.5rem 1rem; 278 + border-radius: var(--radius-base); 279 + font-size: var(--text-base); 280 font-family: inherit; 281 cursor: pointer; 282 transition: all 0.2s; ··· 309 } 310 311 .tangled-icon { 312 + border-radius: var(--radius-sm); 313 opacity: 0.7; 314 transition: opacity 0.2s, box-shadow 0.2s; 315 } ··· 320 } 321 322 h1 { 323 + font-size: var(--text-3xl); 324 margin: 0; 325 color: var(--text-primary); 326 transition: color 0.2s; ··· 353 .nav-link { 354 color: var(--text-secondary); 355 text-decoration: none; 356 + font-size: var(--text-base); 357 transition: all 0.2s; 358 white-space: nowrap; 359 display: flex; 360 align-items: center; 361 gap: 0.4rem; 362 padding: 0.4rem 0.75rem; 363 + border-radius: var(--radius-base); 364 border: 1px solid transparent; 365 } 366 ··· 388 .user-handle { 389 color: var(--text-secondary); 390 text-decoration: none; 391 + font-size: var(--text-base); 392 padding: 0.4rem 0.75rem; 393 background: var(--bg-tertiary); 394 + border-radius: var(--radius-base); 395 border: 1px solid var(--border-default); 396 transition: all 0.2s; 397 white-space: nowrap; ··· 408 border: 1px solid var(--accent); 409 color: var(--accent); 410 padding: 0.5rem 1rem; 411 + border-radius: var(--radius-base); 412 + font-size: var(--text-base); 413 text-decoration: none; 414 transition: all 0.2s; 415 cursor: pointer; ··· 421 color: var(--bg-primary); 422 } 423 424 + /* header mobile breakpoint - see $lib/breakpoints.ts 425 + switch to mobile before margin elements crowd each other */ 426 + @media (max-width: 1300px) { 427 .margin-left, 428 .logout-right { 429 display: none !important; ··· 442 } 443 } 444 445 + /* mobile breakpoint - see $lib/breakpoints.ts */ 446 @media (max-width: 768px) { 447 .header-content { 448 padding: 0.75rem 0.75rem; ··· 467 468 .nav-link { 469 padding: 0.3rem 0.5rem; 470 + font-size: var(--text-sm); 471 } 472 473 .nav-link span { ··· 475 } 476 477 .user-handle { 478 + font-size: var(--text-sm); 479 padding: 0.3rem 0.5rem; 480 } 481 482 .btn-primary { 483 + font-size: var(--text-sm); 484 padding: 0.3rem 0.65rem; 485 } 486 }
+11 -11
frontend/src/lib/components/HiddenTagsFilter.svelte
··· 126 align-items: center; 127 gap: 0.5rem; 128 flex-wrap: wrap; 129 - font-size: 0.8rem; 130 } 131 132 .filter-toggle { ··· 139 color: var(--text-tertiary); 140 cursor: pointer; 141 transition: all 0.15s; 142 - border-radius: 6px; 143 } 144 145 .filter-toggle:hover { ··· 157 } 158 159 .filter-count { 160 - font-size: 0.7rem; 161 color: var(--text-tertiary); 162 } 163 164 .filter-label { 165 color: var(--text-tertiary); 166 white-space: nowrap; 167 - font-size: 0.75rem; 168 font-family: inherit; 169 } 170 ··· 183 background: transparent; 184 border: 1px solid var(--border-default); 185 color: var(--text-secondary); 186 - border-radius: 3px; 187 - font-size: 0.75rem; 188 font-family: inherit; 189 cursor: pointer; 190 transition: all 0.15s; ··· 201 } 202 203 .remove-icon { 204 - font-size: 0.8rem; 205 line-height: 1; 206 opacity: 0.5; 207 } ··· 219 padding: 0; 220 background: transparent; 221 border: 1px dashed var(--border-default); 222 - border-radius: 3px; 223 color: var(--text-tertiary); 224 - font-size: 0.8rem; 225 cursor: pointer; 226 transition: all 0.15s; 227 } ··· 236 background: transparent; 237 border: 1px solid var(--border-default); 238 color: var(--text-primary); 239 - font-size: 0.75rem; 240 font-family: inherit; 241 min-height: 24px; 242 width: 70px; 243 outline: none; 244 - border-radius: 3px; 245 } 246 247 .add-input:focus {
··· 126 align-items: center; 127 gap: 0.5rem; 128 flex-wrap: wrap; 129 + font-size: var(--text-sm); 130 } 131 132 .filter-toggle { ··· 139 color: var(--text-tertiary); 140 cursor: pointer; 141 transition: all 0.15s; 142 + border-radius: var(--radius-base); 143 } 144 145 .filter-toggle:hover { ··· 157 } 158 159 .filter-count { 160 + font-size: var(--text-xs); 161 color: var(--text-tertiary); 162 } 163 164 .filter-label { 165 color: var(--text-tertiary); 166 white-space: nowrap; 167 + font-size: var(--text-xs); 168 font-family: inherit; 169 } 170 ··· 183 background: transparent; 184 border: 1px solid var(--border-default); 185 color: var(--text-secondary); 186 + border-radius: var(--radius-sm); 187 + font-size: var(--text-xs); 188 font-family: inherit; 189 cursor: pointer; 190 transition: all 0.15s; ··· 201 } 202 203 .remove-icon { 204 + font-size: var(--text-sm); 205 line-height: 1; 206 opacity: 0.5; 207 } ··· 219 padding: 0; 220 background: transparent; 221 border: 1px dashed var(--border-default); 222 + border-radius: var(--radius-sm); 223 color: var(--text-tertiary); 224 + font-size: var(--text-sm); 225 cursor: pointer; 226 transition: all 0.15s; 227 } ··· 236 background: transparent; 237 border: 1px solid var(--border-default); 238 color: var(--text-primary); 239 + font-size: var(--text-xs); 240 font-family: inherit; 241 min-height: 24px; 242 width: 70px; 243 outline: none; 244 + border-radius: var(--radius-sm); 245 } 246 247 .add-input:focus {
+4 -3
frontend/src/lib/components/LikeButton.svelte
··· 6 trackId: number; 7 trackTitle: string; 8 fileId?: string; 9 initialLiked?: boolean; 10 disabled?: boolean; 11 disabledReason?: string; 12 onLikeChange?: (_liked: boolean) => void; 13 } 14 15 - let { trackId, trackTitle, fileId, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 16 17 // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 18 let liked = $derived(initialLiked); ··· 31 32 try { 33 const success = liked 34 - ? await likeTrack(trackId, fileId) 35 : await unlikeTrack(trackId); 36 37 if (!success) { ··· 83 justify-content: center; 84 background: transparent; 85 border: 1px solid var(--border-default); 86 - border-radius: 4px; 87 color: var(--text-tertiary); 88 cursor: pointer; 89 transition: all 0.2s;
··· 6 trackId: number; 7 trackTitle: string; 8 fileId?: string; 9 + gated?: boolean; 10 initialLiked?: boolean; 11 disabled?: boolean; 12 disabledReason?: string; 13 onLikeChange?: (_liked: boolean) => void; 14 } 15 16 + let { trackId, trackTitle, fileId, gated, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 17 18 // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 19 let liked = $derived(initialLiked); ··· 32 33 try { 34 const success = liked 35 + ? await likeTrack(trackId, fileId, gated) 36 : await unlikeTrack(trackId); 37 38 if (!success) { ··· 84 justify-content: center; 85 background: transparent; 86 border: 1px solid var(--border-default); 87 + border-radius: var(--radius-sm); 88 color: var(--text-tertiary); 89 cursor: pointer; 90 transition: all 0.2s;
+9 -9
frontend/src/lib/components/LikersTooltip.svelte
··· 134 margin-bottom: 0.5rem; 135 background: var(--bg-secondary); 136 border: 1px solid var(--border-default); 137 - border-radius: 8px; 138 padding: 0.75rem; 139 min-width: 240px; 140 max-width: 320px; ··· 155 .error, 156 .empty { 157 color: var(--text-tertiary); 158 - font-size: 0.85rem; 159 text-align: center; 160 padding: 0.5rem; 161 } ··· 177 align-items: center; 178 gap: 0.75rem; 179 padding: 0.5rem; 180 - border-radius: 6px; 181 text-decoration: none; 182 transition: background 0.2s; 183 } ··· 190 .avatar-placeholder { 191 width: 32px; 192 height: 32px; 193 - border-radius: 50%; 194 flex-shrink: 0; 195 } 196 ··· 206 justify-content: center; 207 color: var(--text-tertiary); 208 font-weight: 600; 209 - font-size: 0.9rem; 210 } 211 212 .liker-info { ··· 217 .display-name { 218 color: var(--text-primary); 219 font-weight: 500; 220 - font-size: 0.9rem; 221 white-space: nowrap; 222 overflow: hidden; 223 text-overflow: ellipsis; ··· 225 226 .handle { 227 color: var(--text-tertiary); 228 - font-size: 0.8rem; 229 white-space: nowrap; 230 overflow: hidden; 231 text-overflow: ellipsis; ··· 233 234 .liked-time { 235 color: var(--text-muted); 236 - font-size: 0.75rem; 237 flex-shrink: 0; 238 } 239 ··· 248 249 .likers-list::-webkit-scrollbar-thumb { 250 background: var(--border-default); 251 - border-radius: 3px; 252 } 253 254 .likers-list::-webkit-scrollbar-thumb:hover {
··· 134 margin-bottom: 0.5rem; 135 background: var(--bg-secondary); 136 border: 1px solid var(--border-default); 137 + border-radius: var(--radius-md); 138 padding: 0.75rem; 139 min-width: 240px; 140 max-width: 320px; ··· 155 .error, 156 .empty { 157 color: var(--text-tertiary); 158 + font-size: var(--text-sm); 159 text-align: center; 160 padding: 0.5rem; 161 } ··· 177 align-items: center; 178 gap: 0.75rem; 179 padding: 0.5rem; 180 + border-radius: var(--radius-base); 181 text-decoration: none; 182 transition: background 0.2s; 183 } ··· 190 .avatar-placeholder { 191 width: 32px; 192 height: 32px; 193 + border-radius: var(--radius-full); 194 flex-shrink: 0; 195 } 196 ··· 206 justify-content: center; 207 color: var(--text-tertiary); 208 font-weight: 600; 209 + font-size: var(--text-base); 210 } 211 212 .liker-info { ··· 217 .display-name { 218 color: var(--text-primary); 219 font-weight: 500; 220 + font-size: var(--text-base); 221 white-space: nowrap; 222 overflow: hidden; 223 text-overflow: ellipsis; ··· 225 226 .handle { 227 color: var(--text-tertiary); 228 + font-size: var(--text-sm); 229 white-space: nowrap; 230 overflow: hidden; 231 text-overflow: ellipsis; ··· 233 234 .liked-time { 235 color: var(--text-muted); 236 + font-size: var(--text-xs); 237 flex-shrink: 0; 238 } 239 ··· 248 249 .likers-list::-webkit-scrollbar-thumb { 250 background: var(--border-default); 251 + border-radius: var(--radius-sm); 252 } 253 254 .likers-list::-webkit-scrollbar-thumb:hover {
+8 -8
frontend/src/lib/components/LinksMenu.svelte
··· 140 height: 32px; 141 background: transparent; 142 border: 1px solid var(--border-default); 143 - border-radius: 6px; 144 color: var(--text-secondary); 145 cursor: pointer; 146 transition: all 0.2s; ··· 171 width: min(320px, calc(100vw - 2rem)); 172 background: var(--bg-secondary); 173 border: 1px solid var(--border-default); 174 - border-radius: 12px; 175 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 176 z-index: 101; 177 animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); ··· 186 } 187 188 .menu-header span { 189 - font-size: 0.9rem; 190 font-weight: 600; 191 color: var(--text-primary); 192 text-transform: uppercase; ··· 201 height: 28px; 202 background: transparent; 203 border: none; 204 - border-radius: 4px; 205 color: var(--text-secondary); 206 cursor: pointer; 207 transition: all 0.2s; ··· 224 gap: 1rem; 225 padding: 1rem; 226 background: transparent; 227 - border-radius: 8px; 228 text-decoration: none; 229 color: var(--text-primary); 230 transition: all 0.2s; ··· 245 } 246 247 .tangled-menu-icon { 248 - border-radius: 4px; 249 opacity: 0.7; 250 transition: opacity 0.2s, box-shadow 0.2s; 251 } ··· 263 } 264 265 .link-title { 266 - font-size: 0.95rem; 267 font-weight: 500; 268 color: var(--text-primary); 269 } 270 271 .link-subtitle { 272 - font-size: 0.8rem; 273 color: var(--text-tertiary); 274 } 275
··· 140 height: 32px; 141 background: transparent; 142 border: 1px solid var(--border-default); 143 + border-radius: var(--radius-base); 144 color: var(--text-secondary); 145 cursor: pointer; 146 transition: all 0.2s; ··· 171 width: min(320px, calc(100vw - 2rem)); 172 background: var(--bg-secondary); 173 border: 1px solid var(--border-default); 174 + border-radius: var(--radius-lg); 175 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 176 z-index: 101; 177 animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); ··· 186 } 187 188 .menu-header span { 189 + font-size: var(--text-base); 190 font-weight: 600; 191 color: var(--text-primary); 192 text-transform: uppercase; ··· 201 height: 28px; 202 background: transparent; 203 border: none; 204 + border-radius: var(--radius-sm); 205 color: var(--text-secondary); 206 cursor: pointer; 207 transition: all 0.2s; ··· 224 gap: 1rem; 225 padding: 1rem; 226 background: transparent; 227 + border-radius: var(--radius-md); 228 text-decoration: none; 229 color: var(--text-primary); 230 transition: all 0.2s; ··· 245 } 246 247 .tangled-menu-icon { 248 + border-radius: var(--radius-sm); 249 opacity: 0.7; 250 transition: opacity 0.2s, box-shadow 0.2s; 251 } ··· 263 } 264 265 .link-title { 266 + font-size: var(--text-base); 267 font-weight: 500; 268 color: var(--text-primary); 269 } 270 271 .link-subtitle { 272 + font-size: var(--text-sm); 273 color: var(--text-tertiary); 274 } 275
+4 -4
frontend/src/lib/components/MigrationBanner.svelte
··· 152 .migration-banner { 153 background: var(--bg-tertiary); 154 border: 1px solid var(--border-default); 155 - border-radius: 8px; 156 padding: 1rem; 157 margin-bottom: 1.5rem; 158 } ··· 190 gap: 1rem; 191 background: color-mix(in srgb, var(--success) 10%, transparent); 192 border: 1px solid color-mix(in srgb, var(--success) 30%, transparent); 193 - border-radius: 6px; 194 padding: 1rem; 195 animation: slideIn 0.3s ease-out; 196 } ··· 239 .collection-name { 240 background: color-mix(in srgb, var(--text-primary) 5%, transparent); 241 padding: 0.15em 0.4em; 242 - border-radius: 3px; 243 font-family: monospace; 244 font-size: 0.95em; 245 color: var(--text-primary); ··· 265 .migrate-button, 266 .dismiss-button { 267 padding: 0.5rem 1rem; 268 - border-radius: 4px; 269 font-size: 0.9em; 270 font-family: inherit; 271 cursor: pointer;
··· 152 .migration-banner { 153 background: var(--bg-tertiary); 154 border: 1px solid var(--border-default); 155 + border-radius: var(--radius-md); 156 padding: 1rem; 157 margin-bottom: 1.5rem; 158 } ··· 190 gap: 1rem; 191 background: color-mix(in srgb, var(--success) 10%, transparent); 192 border: 1px solid color-mix(in srgb, var(--success) 30%, transparent); 193 + border-radius: var(--radius-base); 194 padding: 1rem; 195 animation: slideIn 0.3s ease-out; 196 } ··· 239 .collection-name { 240 background: color-mix(in srgb, var(--text-primary) 5%, transparent); 241 padding: 0.15em 0.4em; 242 + border-radius: var(--radius-sm); 243 font-family: monospace; 244 font-size: 0.95em; 245 color: var(--text-primary); ··· 265 .migrate-button, 266 .dismiss-button { 267 padding: 0.5rem 1rem; 268 + border-radius: var(--radius-sm); 269 font-size: 0.9em; 270 font-family: inherit; 271 cursor: pointer;
+4 -4
frontend/src/lib/components/PlatformStats.svelte
··· 182 gap: 0.5rem; 183 margin-bottom: 0.75rem; 184 color: var(--text-secondary); 185 - font-size: 0.7rem; 186 font-weight: 600; 187 text-transform: uppercase; 188 letter-spacing: 0.05em; ··· 203 background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 204 background-size: 200% 100%; 205 animation: shimmer 1.5s ease-in-out infinite; 206 - border-radius: 6px; 207 } 208 209 .stats-menu-grid { ··· 219 gap: 0.15rem; 220 padding: 0.6rem 0.4rem; 221 background: var(--bg-tertiary, #1a1a1a); 222 - border-radius: 6px; 223 } 224 225 .menu-stat-icon { ··· 229 } 230 231 .stats-menu-value { 232 - font-size: 0.95rem; 233 font-weight: 600; 234 color: var(--text-primary); 235 font-variant-numeric: tabular-nums;
··· 182 gap: 0.5rem; 183 margin-bottom: 0.75rem; 184 color: var(--text-secondary); 185 + font-size: var(--text-xs); 186 font-weight: 600; 187 text-transform: uppercase; 188 letter-spacing: 0.05em; ··· 203 background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 204 background-size: 200% 100%; 205 animation: shimmer 1.5s ease-in-out infinite; 206 + border-radius: var(--radius-base); 207 } 208 209 .stats-menu-grid { ··· 219 gap: 0.15rem; 220 padding: 0.6rem 0.4rem; 221 background: var(--bg-tertiary, #1a1a1a); 222 + border-radius: var(--radius-base); 223 } 224 225 .menu-stat-icon { ··· 229 } 230 231 .stats-menu-value { 232 + font-size: var(--text-base); 233 font-weight: 600; 234 color: var(--text-primary); 235 font-variant-numeric: tabular-nums;
+19 -19
frontend/src/lib/components/ProfileMenu.svelte
··· 276 height: 44px; 277 background: transparent; 278 border: 1px solid var(--border-default); 279 - border-radius: 8px; 280 color: var(--text-secondary); 281 cursor: pointer; 282 transition: all 0.15s; ··· 311 overflow-y: auto; 312 background: var(--bg-secondary); 313 border: 1px solid var(--border-default); 314 - border-radius: 16px; 315 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 316 z-index: 101; 317 animation: slideIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); ··· 326 } 327 328 .menu-header span { 329 - font-size: 0.9rem; 330 font-weight: 600; 331 color: var(--text-primary); 332 text-transform: uppercase; ··· 341 height: 36px; 342 background: transparent; 343 border: none; 344 - border-radius: 8px; 345 color: var(--text-secondary); 346 cursor: pointer; 347 transition: all 0.15s; ··· 371 min-height: 56px; 372 background: transparent; 373 border: none; 374 - border-radius: 12px; 375 text-decoration: none; 376 color: var(--text-primary); 377 font-family: inherit; ··· 414 } 415 416 .item-title { 417 - font-size: 0.95rem; 418 font-weight: 500; 419 color: var(--text-primary); 420 } 421 422 .item-subtitle { 423 - font-size: 0.8rem; 424 color: var(--text-tertiary); 425 overflow: hidden; 426 text-overflow: ellipsis; ··· 440 padding: 0.5rem 0.75rem; 441 background: transparent; 442 border: none; 443 - border-radius: 6px; 444 color: var(--text-secondary); 445 font-family: inherit; 446 - font-size: 0.85rem; 447 cursor: pointer; 448 transition: all 0.15s; 449 -webkit-tap-highlight-color: transparent; ··· 469 470 .settings-section h3 { 471 margin: 0; 472 - font-size: 0.75rem; 473 text-transform: uppercase; 474 letter-spacing: 0.08em; 475 color: var(--text-tertiary); ··· 490 min-height: 54px; 491 background: var(--bg-tertiary); 492 border: 1px solid var(--border-default); 493 - border-radius: 8px; 494 color: var(--text-secondary); 495 cursor: pointer; 496 transition: all 0.15s; ··· 518 } 519 520 .theme-btn span { 521 - font-size: 0.7rem; 522 text-transform: uppercase; 523 letter-spacing: 0.05em; 524 } ··· 533 width: 44px; 534 height: 44px; 535 border: 1px solid var(--border-default); 536 - border-radius: 8px; 537 cursor: pointer; 538 background: transparent; 539 flex-shrink: 0; ··· 544 } 545 546 .color-input::-webkit-color-swatch { 547 - border-radius: 4px; 548 border: none; 549 } 550 ··· 557 .preset-btn { 558 width: 36px; 559 height: 36px; 560 - border-radius: 6px; 561 border: 2px solid transparent; 562 cursor: pointer; 563 transition: all 0.15s; ··· 583 align-items: center; 584 gap: 0.75rem; 585 color: var(--text-primary); 586 - font-size: 0.9rem; 587 cursor: pointer; 588 padding: 0.5rem 0; 589 } ··· 592 appearance: none; 593 width: 48px; 594 height: 28px; 595 - border-radius: 999px; 596 background: var(--border-default); 597 position: relative; 598 cursor: pointer; ··· 608 left: 3px; 609 width: 20px; 610 height: 20px; 611 - border-radius: 50%; 612 background: var(--text-secondary); 613 transition: transform 0.15s, background 0.15s; 614 } ··· 635 border-top: 1px solid var(--border-subtle); 636 color: var(--text-secondary); 637 text-decoration: none; 638 - font-size: 0.9rem; 639 transition: color 0.15s; 640 } 641
··· 276 height: 44px; 277 background: transparent; 278 border: 1px solid var(--border-default); 279 + border-radius: var(--radius-md); 280 color: var(--text-secondary); 281 cursor: pointer; 282 transition: all 0.15s; ··· 311 overflow-y: auto; 312 background: var(--bg-secondary); 313 border: 1px solid var(--border-default); 314 + border-radius: var(--radius-xl); 315 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 316 z-index: 101; 317 animation: slideIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); ··· 326 } 327 328 .menu-header span { 329 + font-size: var(--text-base); 330 font-weight: 600; 331 color: var(--text-primary); 332 text-transform: uppercase; ··· 341 height: 36px; 342 background: transparent; 343 border: none; 344 + border-radius: var(--radius-md); 345 color: var(--text-secondary); 346 cursor: pointer; 347 transition: all 0.15s; ··· 371 min-height: 56px; 372 background: transparent; 373 border: none; 374 + border-radius: var(--radius-lg); 375 text-decoration: none; 376 color: var(--text-primary); 377 font-family: inherit; ··· 414 } 415 416 .item-title { 417 + font-size: var(--text-base); 418 font-weight: 500; 419 color: var(--text-primary); 420 } 421 422 .item-subtitle { 423 + font-size: var(--text-sm); 424 color: var(--text-tertiary); 425 overflow: hidden; 426 text-overflow: ellipsis; ··· 440 padding: 0.5rem 0.75rem; 441 background: transparent; 442 border: none; 443 + border-radius: var(--radius-base); 444 color: var(--text-secondary); 445 font-family: inherit; 446 + font-size: var(--text-sm); 447 cursor: pointer; 448 transition: all 0.15s; 449 -webkit-tap-highlight-color: transparent; ··· 469 470 .settings-section h3 { 471 margin: 0; 472 + font-size: var(--text-xs); 473 text-transform: uppercase; 474 letter-spacing: 0.08em; 475 color: var(--text-tertiary); ··· 490 min-height: 54px; 491 background: var(--bg-tertiary); 492 border: 1px solid var(--border-default); 493 + border-radius: var(--radius-md); 494 color: var(--text-secondary); 495 cursor: pointer; 496 transition: all 0.15s; ··· 518 } 519 520 .theme-btn span { 521 + font-size: var(--text-xs); 522 text-transform: uppercase; 523 letter-spacing: 0.05em; 524 } ··· 533 width: 44px; 534 height: 44px; 535 border: 1px solid var(--border-default); 536 + border-radius: var(--radius-md); 537 cursor: pointer; 538 background: transparent; 539 flex-shrink: 0; ··· 544 } 545 546 .color-input::-webkit-color-swatch { 547 + border-radius: var(--radius-sm); 548 border: none; 549 } 550 ··· 557 .preset-btn { 558 width: 36px; 559 height: 36px; 560 + border-radius: var(--radius-base); 561 border: 2px solid transparent; 562 cursor: pointer; 563 transition: all 0.15s; ··· 583 align-items: center; 584 gap: 0.75rem; 585 color: var(--text-primary); 586 + font-size: var(--text-base); 587 cursor: pointer; 588 padding: 0.5rem 0; 589 } ··· 592 appearance: none; 593 width: 48px; 594 height: 28px; 595 + border-radius: var(--radius-full); 596 background: var(--border-default); 597 position: relative; 598 cursor: pointer; ··· 608 left: 3px; 609 width: 20px; 610 height: 20px; 611 + border-radius: var(--radius-full); 612 background: var(--text-secondary); 613 transition: transform 0.15s, background 0.15s; 614 } ··· 635 border-top: 1px solid var(--border-subtle); 636 color: var(--text-secondary); 637 text-decoration: none; 638 + font-size: var(--text-base); 639 transition: color 0.15s; 640 } 641
+16 -16
frontend/src/lib/components/Queue.svelte
··· 249 250 .queue-header h2 { 251 margin: 0; 252 - font-size: 1rem; 253 text-transform: uppercase; 254 letter-spacing: 0.12em; 255 color: var(--text-tertiary); ··· 263 264 .clear-btn { 265 padding: 0.25rem 0.75rem; 266 - font-size: 0.75rem; 267 font-family: inherit; 268 text-transform: uppercase; 269 letter-spacing: 0.08em; 270 background: transparent; 271 border: 1px solid var(--border-subtle); 272 color: var(--text-tertiary); 273 - border-radius: 4px; 274 cursor: pointer; 275 transition: all 0.15s ease; 276 } ··· 290 } 291 292 .section-label { 293 - font-size: 0.75rem; 294 text-transform: uppercase; 295 letter-spacing: 0.08em; 296 color: var(--text-tertiary); ··· 302 align-items: center; 303 justify-content: space-between; 304 padding: 1rem 1.1rem; 305 - border-radius: 10px; 306 background: var(--bg-secondary); 307 border: 1px solid var(--border-default); 308 gap: 1rem; ··· 316 } 317 318 .now-playing-card .track-artist { 319 - font-size: 0.9rem; 320 color: var(--text-secondary); 321 } 322 ··· 344 justify-content: space-between; 345 align-items: center; 346 color: var(--text-tertiary); 347 - font-size: 0.85rem; 348 text-transform: uppercase; 349 letter-spacing: 0.08em; 350 } 351 352 .section-header h3 { 353 margin: 0; 354 - font-size: 0.85rem; 355 font-weight: 600; 356 color: var(--text-secondary); 357 text-transform: uppercase; ··· 372 align-items: center; 373 gap: 0.5rem; 374 padding: 0.85rem 0.9rem; 375 - border-radius: 8px; 376 cursor: pointer; 377 transition: all 0.2s; 378 border: 1px solid var(--border-subtle); ··· 412 color: var(--text-muted); 413 cursor: grab; 414 touch-action: none; 415 - border-radius: 4px; 416 transition: all 0.2s; 417 flex-shrink: 0; 418 } ··· 449 } 450 451 .track-artist { 452 - font-size: 0.85rem; 453 color: var(--text-tertiary); 454 white-space: nowrap; 455 overflow: hidden; ··· 476 align-items: center; 477 justify-content: center; 478 transition: all 0.2s; 479 - border-radius: 4px; 480 opacity: 0; 481 flex-shrink: 0; 482 } ··· 499 500 .empty-up-next { 501 border: 1px dashed var(--border-subtle); 502 - border-radius: 6px; 503 padding: 1.25rem; 504 text-align: center; 505 color: var(--text-tertiary); ··· 524 525 .empty-state p { 526 margin: 0.5rem 0 0.25rem; 527 - font-size: 1.1rem; 528 color: var(--text-secondary); 529 } 530 531 .empty-state span { 532 - font-size: 0.9rem; 533 } 534 535 .queue-tracks::-webkit-scrollbar { ··· 542 543 .queue-tracks::-webkit-scrollbar-thumb { 544 background: var(--border-default); 545 - border-radius: 4px; 546 } 547 548 .queue-tracks::-webkit-scrollbar-thumb:hover {
··· 249 250 .queue-header h2 { 251 margin: 0; 252 + font-size: var(--text-lg); 253 text-transform: uppercase; 254 letter-spacing: 0.12em; 255 color: var(--text-tertiary); ··· 263 264 .clear-btn { 265 padding: 0.25rem 0.75rem; 266 + font-size: var(--text-xs); 267 font-family: inherit; 268 text-transform: uppercase; 269 letter-spacing: 0.08em; 270 background: transparent; 271 border: 1px solid var(--border-subtle); 272 color: var(--text-tertiary); 273 + border-radius: var(--radius-sm); 274 cursor: pointer; 275 transition: all 0.15s ease; 276 } ··· 290 } 291 292 .section-label { 293 + font-size: var(--text-xs); 294 text-transform: uppercase; 295 letter-spacing: 0.08em; 296 color: var(--text-tertiary); ··· 302 align-items: center; 303 justify-content: space-between; 304 padding: 1rem 1.1rem; 305 + border-radius: var(--radius-md); 306 background: var(--bg-secondary); 307 border: 1px solid var(--border-default); 308 gap: 1rem; ··· 316 } 317 318 .now-playing-card .track-artist { 319 + font-size: var(--text-base); 320 color: var(--text-secondary); 321 } 322 ··· 344 justify-content: space-between; 345 align-items: center; 346 color: var(--text-tertiary); 347 + font-size: var(--text-sm); 348 text-transform: uppercase; 349 letter-spacing: 0.08em; 350 } 351 352 .section-header h3 { 353 margin: 0; 354 + font-size: var(--text-sm); 355 font-weight: 600; 356 color: var(--text-secondary); 357 text-transform: uppercase; ··· 372 align-items: center; 373 gap: 0.5rem; 374 padding: 0.85rem 0.9rem; 375 + border-radius: var(--radius-md); 376 cursor: pointer; 377 transition: all 0.2s; 378 border: 1px solid var(--border-subtle); ··· 412 color: var(--text-muted); 413 cursor: grab; 414 touch-action: none; 415 + border-radius: var(--radius-sm); 416 transition: all 0.2s; 417 flex-shrink: 0; 418 } ··· 449 } 450 451 .track-artist { 452 + font-size: var(--text-sm); 453 color: var(--text-tertiary); 454 white-space: nowrap; 455 overflow: hidden; ··· 476 align-items: center; 477 justify-content: center; 478 transition: all 0.2s; 479 + border-radius: var(--radius-sm); 480 opacity: 0; 481 flex-shrink: 0; 482 } ··· 499 500 .empty-up-next { 501 border: 1px dashed var(--border-subtle); 502 + border-radius: var(--radius-base); 503 padding: 1.25rem; 504 text-align: center; 505 color: var(--text-tertiary); ··· 524 525 .empty-state p { 526 margin: 0.5rem 0 0.25rem; 527 + font-size: var(--text-xl); 528 color: var(--text-secondary); 529 } 530 531 .empty-state span { 532 + font-size: var(--text-base); 533 } 534 535 .queue-tracks::-webkit-scrollbar { ··· 542 543 .queue-tracks::-webkit-scrollbar-thumb { 544 background: var(--border-default); 545 + border-radius: var(--radius-sm); 546 } 547 548 .queue-tracks::-webkit-scrollbar-thumb:hover {
+20 -20
frontend/src/lib/components/SearchModal.svelte
··· 276 backdrop-filter: blur(20px) saturate(180%); 277 -webkit-backdrop-filter: blur(20px) saturate(180%); 278 border: 1px solid var(--border-subtle); 279 - border-radius: 16px; 280 box-shadow: 281 0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent), 282 0 0 1px var(--border-subtle) inset; ··· 303 background: transparent; 304 border: none; 305 outline: none; 306 - font-size: 1rem; 307 font-family: inherit; 308 color: var(--text-primary); 309 } ··· 313 } 314 315 .search-shortcut { 316 - font-size: 0.7rem; 317 padding: 0.25rem 0.5rem; 318 background: var(--bg-tertiary); 319 border: 1px solid var(--border-default); 320 - border-radius: 5px; 321 color: var(--text-muted); 322 font-family: inherit; 323 } ··· 327 height: 16px; 328 border: 2px solid var(--border-default); 329 border-top-color: var(--accent); 330 - border-radius: 50%; 331 animation: spin 0.6s linear infinite; 332 } 333 ··· 351 352 .search-results::-webkit-scrollbar-track { 353 background: transparent; 354 - border-radius: 4px; 355 } 356 357 .search-results::-webkit-scrollbar-thumb { 358 background: var(--border-default); 359 - border-radius: 4px; 360 } 361 362 .search-results::-webkit-scrollbar-thumb:hover { ··· 371 padding: 0.75rem; 372 background: transparent; 373 border: none; 374 - border-radius: 8px; 375 cursor: pointer; 376 text-align: left; 377 font-family: inherit; ··· 396 align-items: center; 397 justify-content: center; 398 background: var(--bg-tertiary); 399 - border-radius: 8px; 400 - font-size: 0.9rem; 401 flex-shrink: 0; 402 position: relative; 403 overflow: hidden; ··· 409 width: 100%; 410 height: 100%; 411 object-fit: cover; 412 - border-radius: 8px; 413 } 414 415 .result-icon[data-type='track'] { ··· 441 } 442 443 .result-title { 444 - font-size: 0.9rem; 445 font-weight: 500; 446 white-space: nowrap; 447 overflow: hidden; ··· 449 } 450 451 .result-subtitle { 452 - font-size: 0.75rem; 453 color: var(--text-secondary); 454 white-space: nowrap; 455 overflow: hidden; ··· 463 color: var(--text-muted); 464 padding: 0.2rem 0.45rem; 465 background: var(--bg-tertiary); 466 - border-radius: 4px; 467 flex-shrink: 0; 468 } 469 ··· 471 padding: 2rem; 472 text-align: center; 473 color: var(--text-secondary); 474 - font-size: 0.9rem; 475 } 476 477 .search-hints { ··· 482 .search-hints p { 483 margin: 0 0 1rem 0; 484 color: var(--text-secondary); 485 - font-size: 0.85rem; 486 } 487 488 .hint-shortcuts { ··· 490 justify-content: center; 491 gap: 1.5rem; 492 color: var(--text-muted); 493 - font-size: 0.75rem; 494 } 495 496 .hint-shortcuts span { ··· 504 padding: 0.15rem 0.35rem; 505 background: var(--bg-tertiary); 506 border: 1px solid var(--border-default); 507 - border-radius: 4px; 508 font-family: inherit; 509 } 510 ··· 512 padding: 1rem; 513 text-align: center; 514 color: var(--error); 515 - font-size: 0.85rem; 516 } 517 518 /* mobile optimizations */ ··· 535 } 536 537 .search-input::placeholder { 538 - font-size: 0.85rem; 539 } 540 541 .search-results {
··· 276 backdrop-filter: blur(20px) saturate(180%); 277 -webkit-backdrop-filter: blur(20px) saturate(180%); 278 border: 1px solid var(--border-subtle); 279 + border-radius: var(--radius-xl); 280 box-shadow: 281 0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent), 282 0 0 1px var(--border-subtle) inset; ··· 303 background: transparent; 304 border: none; 305 outline: none; 306 + font-size: var(--text-lg); 307 font-family: inherit; 308 color: var(--text-primary); 309 } ··· 313 } 314 315 .search-shortcut { 316 + font-size: var(--text-xs); 317 padding: 0.25rem 0.5rem; 318 background: var(--bg-tertiary); 319 border: 1px solid var(--border-default); 320 + border-radius: var(--radius-sm); 321 color: var(--text-muted); 322 font-family: inherit; 323 } ··· 327 height: 16px; 328 border: 2px solid var(--border-default); 329 border-top-color: var(--accent); 330 + border-radius: var(--radius-full); 331 animation: spin 0.6s linear infinite; 332 } 333 ··· 351 352 .search-results::-webkit-scrollbar-track { 353 background: transparent; 354 + border-radius: var(--radius-sm); 355 } 356 357 .search-results::-webkit-scrollbar-thumb { 358 background: var(--border-default); 359 + border-radius: var(--radius-sm); 360 } 361 362 .search-results::-webkit-scrollbar-thumb:hover { ··· 371 padding: 0.75rem; 372 background: transparent; 373 border: none; 374 + border-radius: var(--radius-md); 375 cursor: pointer; 376 text-align: left; 377 font-family: inherit; ··· 396 align-items: center; 397 justify-content: center; 398 background: var(--bg-tertiary); 399 + border-radius: var(--radius-md); 400 + font-size: var(--text-base); 401 flex-shrink: 0; 402 position: relative; 403 overflow: hidden; ··· 409 width: 100%; 410 height: 100%; 411 object-fit: cover; 412 + border-radius: var(--radius-md); 413 } 414 415 .result-icon[data-type='track'] { ··· 441 } 442 443 .result-title { 444 + font-size: var(--text-base); 445 font-weight: 500; 446 white-space: nowrap; 447 overflow: hidden; ··· 449 } 450 451 .result-subtitle { 452 + font-size: var(--text-xs); 453 color: var(--text-secondary); 454 white-space: nowrap; 455 overflow: hidden; ··· 463 color: var(--text-muted); 464 padding: 0.2rem 0.45rem; 465 background: var(--bg-tertiary); 466 + border-radius: var(--radius-sm); 467 flex-shrink: 0; 468 } 469 ··· 471 padding: 2rem; 472 text-align: center; 473 color: var(--text-secondary); 474 + font-size: var(--text-base); 475 } 476 477 .search-hints { ··· 482 .search-hints p { 483 margin: 0 0 1rem 0; 484 color: var(--text-secondary); 485 + font-size: var(--text-sm); 486 } 487 488 .hint-shortcuts { ··· 490 justify-content: center; 491 gap: 1.5rem; 492 color: var(--text-muted); 493 + font-size: var(--text-xs); 494 } 495 496 .hint-shortcuts span { ··· 504 padding: 0.15rem 0.35rem; 505 background: var(--bg-tertiary); 506 border: 1px solid var(--border-default); 507 + border-radius: var(--radius-sm); 508 font-family: inherit; 509 } 510 ··· 512 padding: 1rem; 513 text-align: center; 514 color: var(--error); 515 + font-size: var(--text-sm); 516 } 517 518 /* mobile optimizations */ ··· 535 } 536 537 .search-input::placeholder { 538 + font-size: var(--text-sm); 539 } 540 541 .search-results {
+1 -1
frontend/src/lib/components/SearchTrigger.svelte
··· 24 border: 1px solid var(--border-default); 25 color: var(--text-secondary); 26 padding: 0.5rem; 27 - border-radius: 4px; 28 cursor: pointer; 29 transition: all 0.2s; 30 display: flex;
··· 24 border: 1px solid var(--border-default); 25 color: var(--text-secondary); 26 padding: 0.5rem; 27 + border-radius: var(--radius-sm); 28 cursor: pointer; 29 transition: all 0.2s; 30 display: flex;
+3 -3
frontend/src/lib/components/SensitiveImage.svelte
··· 70 margin-bottom: 4px; 71 background: var(--bg-primary); 72 border: 1px solid var(--border-default); 73 - border-radius: 4px; 74 padding: 0.25rem 0.5rem; 75 - font-size: 0.7rem; 76 color: var(--text-tertiary); 77 white-space: nowrap; 78 opacity: 0; ··· 89 transform: translate(-50%, -50%); 90 margin-bottom: 0; 91 padding: 0.5rem 0.75rem; 92 - font-size: 0.8rem; 93 } 94 95 .sensitive-wrapper.blur:hover .sensitive-tooltip {
··· 70 margin-bottom: 4px; 71 background: var(--bg-primary); 72 border: 1px solid var(--border-default); 73 + border-radius: var(--radius-sm); 74 padding: 0.25rem 0.5rem; 75 + font-size: var(--text-xs); 76 color: var(--text-tertiary); 77 white-space: nowrap; 78 opacity: 0; ··· 89 transform: translate(-50%, -50%); 90 margin-bottom: 0; 91 padding: 0.5rem 0.75rem; 92 + font-size: var(--text-sm); 93 } 94 95 .sensitive-wrapper.blur:hover .sensitive-tooltip {
+14 -14
frontend/src/lib/components/SettingsMenu.svelte
··· 181 border: 1px solid var(--border-default); 182 color: var(--text-secondary); 183 padding: 0.5rem; 184 - border-radius: 4px; 185 cursor: pointer; 186 transition: all 0.2s; 187 display: flex; ··· 200 right: 0; 201 background: var(--bg-secondary); 202 border: 1px solid var(--border-default); 203 - border-radius: 8px; 204 padding: 1.25rem; 205 min-width: 280px; 206 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); ··· 216 align-items: center; 217 color: var(--text-primary); 218 font-weight: 600; 219 - font-size: 0.95rem; 220 } 221 222 .close-btn { ··· 245 246 .settings-section h3 { 247 margin: 0; 248 - font-size: 0.85rem; 249 text-transform: uppercase; 250 letter-spacing: 0.08em; 251 color: var(--text-tertiary); ··· 265 padding: 0.6rem 0.5rem; 266 background: var(--bg-tertiary); 267 border: 1px solid var(--border-default); 268 - border-radius: 6px; 269 color: var(--text-secondary); 270 cursor: pointer; 271 transition: all 0.2s; ··· 288 } 289 290 .theme-btn span { 291 - font-size: 0.7rem; 292 text-transform: uppercase; 293 letter-spacing: 0.05em; 294 } ··· 303 width: 48px; 304 height: 32px; 305 border: 1px solid var(--border-default); 306 - border-radius: 4px; 307 cursor: pointer; 308 background: transparent; 309 } ··· 319 320 .color-value { 321 font-family: monospace; 322 - font-size: 0.85rem; 323 color: var(--text-secondary); 324 } 325 ··· 332 .preset-btn { 333 width: 32px; 334 height: 32px; 335 - border-radius: 4px; 336 border: 2px solid transparent; 337 cursor: pointer; 338 transition: all 0.2s; ··· 354 align-items: center; 355 gap: 0.65rem; 356 color: var(--text-primary); 357 - font-size: 0.9rem; 358 } 359 360 .toggle input { 361 appearance: none; 362 width: 42px; 363 height: 22px; 364 - border-radius: 999px; 365 background: var(--border-default); 366 position: relative; 367 cursor: pointer; ··· 377 left: 2px; 378 width: 16px; 379 height: 16px; 380 - border-radius: 50%; 381 background: var(--text-secondary); 382 transition: transform 0.2s, background 0.2s; 383 } ··· 403 .toggle-hint { 404 margin: 0; 405 color: var(--text-tertiary); 406 - font-size: 0.8rem; 407 line-height: 1.3; 408 } 409 ··· 415 border-top: 1px solid var(--border-subtle); 416 color: var(--text-secondary); 417 text-decoration: none; 418 - font-size: 0.85rem; 419 transition: color 0.15s; 420 } 421
··· 181 border: 1px solid var(--border-default); 182 color: var(--text-secondary); 183 padding: 0.5rem; 184 + border-radius: var(--radius-sm); 185 cursor: pointer; 186 transition: all 0.2s; 187 display: flex; ··· 200 right: 0; 201 background: var(--bg-secondary); 202 border: 1px solid var(--border-default); 203 + border-radius: var(--radius-md); 204 padding: 1.25rem; 205 min-width: 280px; 206 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); ··· 216 align-items: center; 217 color: var(--text-primary); 218 font-weight: 600; 219 + font-size: var(--text-base); 220 } 221 222 .close-btn { ··· 245 246 .settings-section h3 { 247 margin: 0; 248 + font-size: var(--text-sm); 249 text-transform: uppercase; 250 letter-spacing: 0.08em; 251 color: var(--text-tertiary); ··· 265 padding: 0.6rem 0.5rem; 266 background: var(--bg-tertiary); 267 border: 1px solid var(--border-default); 268 + border-radius: var(--radius-base); 269 color: var(--text-secondary); 270 cursor: pointer; 271 transition: all 0.2s; ··· 288 } 289 290 .theme-btn span { 291 + font-size: var(--text-xs); 292 text-transform: uppercase; 293 letter-spacing: 0.05em; 294 } ··· 303 width: 48px; 304 height: 32px; 305 border: 1px solid var(--border-default); 306 + border-radius: var(--radius-sm); 307 cursor: pointer; 308 background: transparent; 309 } ··· 319 320 .color-value { 321 font-family: monospace; 322 + font-size: var(--text-sm); 323 color: var(--text-secondary); 324 } 325 ··· 332 .preset-btn { 333 width: 32px; 334 height: 32px; 335 + border-radius: var(--radius-sm); 336 border: 2px solid transparent; 337 cursor: pointer; 338 transition: all 0.2s; ··· 354 align-items: center; 355 gap: 0.65rem; 356 color: var(--text-primary); 357 + font-size: var(--text-base); 358 } 359 360 .toggle input { 361 appearance: none; 362 width: 42px; 363 height: 22px; 364 + border-radius: var(--radius-full); 365 background: var(--border-default); 366 position: relative; 367 cursor: pointer; ··· 377 left: 2px; 378 width: 16px; 379 height: 16px; 380 + border-radius: var(--radius-full); 381 background: var(--text-secondary); 382 transition: transform 0.2s, background 0.2s; 383 } ··· 403 .toggle-hint { 404 margin: 0; 405 color: var(--text-tertiary); 406 + font-size: var(--text-sm); 407 line-height: 1.3; 408 } 409 ··· 415 border-top: 1px solid var(--border-subtle); 416 color: var(--text-secondary); 417 text-decoration: none; 418 + font-size: var(--text-sm); 419 transition: color 0.15s; 420 } 421
+3 -3
frontend/src/lib/components/ShareButton.svelte
··· 38 .share-btn { 39 background: var(--glass-btn-bg, transparent); 40 border: 1px solid var(--glass-btn-border, var(--border-default)); 41 - border-radius: 6px; 42 width: 32px; 43 height: 32px; 44 padding: 0; ··· 67 border: 1px solid var(--accent); 68 color: var(--accent); 69 padding: 0.25rem 0.75rem; 70 - border-radius: 4px; 71 - font-size: 0.75rem; 72 white-space: nowrap; 73 pointer-events: none; 74 animation: fadeIn 0.2s ease-in;
··· 38 .share-btn { 39 background: var(--glass-btn-bg, transparent); 40 border: 1px solid var(--glass-btn-border, var(--border-default)); 41 + border-radius: var(--radius-base); 42 width: 32px; 43 height: 32px; 44 padding: 0; ··· 67 border: 1px solid var(--accent); 68 color: var(--accent); 69 padding: 0.25rem 0.75rem; 70 + border-radius: var(--radius-sm); 71 + font-size: var(--text-xs); 72 white-space: nowrap; 73 pointer-events: none; 74 animation: fadeIn 0.2s ease-in;
+2 -2
frontend/src/lib/components/SupporterBadge.svelte
··· 22 padding: 0.2rem 0.5rem; 23 background: color-mix(in srgb, var(--accent) 15%, transparent); 24 border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 25 - border-radius: 4px; 26 color: var(--accent); 27 - font-size: 0.75rem; 28 font-weight: 500; 29 text-transform: lowercase; 30 white-space: nowrap;
··· 22 padding: 0.2rem 0.5rem; 23 background: color-mix(in srgb, var(--accent) 15%, transparent); 24 border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 25 + border-radius: var(--radius-sm); 26 color: var(--accent); 27 + font-size: var(--text-xs); 28 font-weight: 500; 29 text-transform: lowercase; 30 white-space: nowrap;
+10 -10
frontend/src/lib/components/TagInput.svelte
··· 178 padding: 0.75rem; 179 background: var(--bg-primary); 180 border: 1px solid var(--border-default); 181 - border-radius: 4px; 182 min-height: 48px; 183 transition: all 0.2s; 184 } ··· 195 background: color-mix(in srgb, var(--accent) 10%, transparent); 196 border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); 197 color: var(--accent-hover); 198 - border-radius: 20px; 199 - font-size: 0.9rem; 200 font-weight: 500; 201 } 202 ··· 233 background: transparent; 234 border: none; 235 color: var(--text-primary); 236 - font-size: 1rem; 237 font-family: inherit; 238 outline: none; 239 } ··· 249 250 .spinner { 251 color: var(--text-muted); 252 - font-size: 0.85rem; 253 margin-left: auto; 254 } 255 ··· 261 overflow-y: auto; 262 background: var(--bg-secondary); 263 border: 1px solid var(--border-default); 264 - border-radius: 4px; 265 margin-top: 0.25rem; 266 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 267 scrollbar-width: thin; ··· 274 275 .suggestions::-webkit-scrollbar-track { 276 background: var(--bg-primary); 277 - border-radius: 4px; 278 } 279 280 .suggestions::-webkit-scrollbar-thumb { 281 background: var(--border-default); 282 - border-radius: 4px; 283 } 284 285 .suggestions::-webkit-scrollbar-thumb:hover { ··· 317 } 318 319 .tag-count { 320 - font-size: 0.85rem; 321 color: var(--text-tertiary); 322 } 323 ··· 332 333 .tag-chip { 334 padding: 0.3rem 0.5rem; 335 - font-size: 0.85rem; 336 } 337 } 338 </style>
··· 178 padding: 0.75rem; 179 background: var(--bg-primary); 180 border: 1px solid var(--border-default); 181 + border-radius: var(--radius-sm); 182 min-height: 48px; 183 transition: all 0.2s; 184 } ··· 195 background: color-mix(in srgb, var(--accent) 10%, transparent); 196 border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); 197 color: var(--accent-hover); 198 + border-radius: var(--radius-xl); 199 + font-size: var(--text-base); 200 font-weight: 500; 201 } 202 ··· 233 background: transparent; 234 border: none; 235 color: var(--text-primary); 236 + font-size: var(--text-lg); 237 font-family: inherit; 238 outline: none; 239 } ··· 249 250 .spinner { 251 color: var(--text-muted); 252 + font-size: var(--text-sm); 253 margin-left: auto; 254 } 255 ··· 261 overflow-y: auto; 262 background: var(--bg-secondary); 263 border: 1px solid var(--border-default); 264 + border-radius: var(--radius-sm); 265 margin-top: 0.25rem; 266 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 267 scrollbar-width: thin; ··· 274 275 .suggestions::-webkit-scrollbar-track { 276 background: var(--bg-primary); 277 + border-radius: var(--radius-sm); 278 } 279 280 .suggestions::-webkit-scrollbar-thumb { 281 background: var(--border-default); 282 + border-radius: var(--radius-sm); 283 } 284 285 .suggestions::-webkit-scrollbar-thumb:hover { ··· 317 } 318 319 .tag-count { 320 + font-size: var(--text-sm); 321 color: var(--text-tertiary); 322 } 323 ··· 332 333 .tag-chip { 334 padding: 0.3rem 0.5rem; 335 + font-size: var(--text-sm); 336 } 337 } 338 </style>
+5 -5
frontend/src/lib/components/Toast.svelte
··· 61 backdrop-filter: blur(12px); 62 -webkit-backdrop-filter: blur(12px); 63 border: 1px solid rgba(255, 255, 255, 0.06); 64 - border-radius: 8px; 65 pointer-events: none; 66 - font-size: 0.85rem; 67 max-width: 450px; 68 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 69 } 70 71 .toast-icon { 72 - font-size: 0.8rem; 73 flex-shrink: 0; 74 opacity: 0.7; 75 margin-top: 0.1rem; ··· 135 136 .toast { 137 padding: 0.35rem 0.7rem; 138 - font-size: 0.8rem; 139 max-width: 90vw; 140 } 141 142 .toast-icon { 143 - font-size: 0.75rem; 144 } 145 146 .toast-message {
··· 61 backdrop-filter: blur(12px); 62 -webkit-backdrop-filter: blur(12px); 63 border: 1px solid rgba(255, 255, 255, 0.06); 64 + border-radius: var(--radius-md); 65 pointer-events: none; 66 + font-size: var(--text-sm); 67 max-width: 450px; 68 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 69 } 70 71 .toast-icon { 72 + font-size: var(--text-sm); 73 flex-shrink: 0; 74 opacity: 0.7; 75 margin-top: 0.1rem; ··· 135 136 .toast { 137 padding: 0.35rem 0.7rem; 138 + font-size: var(--text-sm); 139 max-width: 90vw; 140 } 141 142 .toast-icon { 143 + font-size: var(--text-xs); 144 } 145 146 .toast-message {
+18 -16
frontend/src/lib/components/TrackActionsMenu.svelte
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 initialLiked: boolean; 14 shareUrl: string; 15 onQueue: () => void; ··· 24 trackUri, 25 trackCid, 26 fileId, 27 initialLiked, 28 shareUrl, 29 onQueue, ··· 101 102 try { 103 const success = liked 104 - ? await likeTrack(trackId, fileId) 105 : await unlikeTrack(trackId); 106 107 if (!success) { ··· 400 justify-content: center; 401 background: transparent; 402 border: 1px solid var(--border-default); 403 - border-radius: 4px; 404 color: var(--text-tertiary); 405 cursor: pointer; 406 transition: all 0.2s; ··· 472 } 473 474 .menu-item span { 475 - font-size: 1rem; 476 font-weight: 400; 477 flex: 1; 478 } ··· 506 border: none; 507 border-bottom: 1px solid var(--border-default); 508 color: var(--text-secondary); 509 - font-size: 0.9rem; 510 font-family: inherit; 511 cursor: pointer; 512 transition: background 0.15s; ··· 532 border: none; 533 border-bottom: 1px solid var(--border-subtle); 534 color: var(--text-primary); 535 - font-size: 1rem; 536 font-family: inherit; 537 cursor: pointer; 538 transition: background 0.15s; ··· 556 .playlist-thumb-placeholder { 557 width: 36px; 558 height: 36px; 559 - border-radius: 4px; 560 flex-shrink: 0; 561 } 562 ··· 588 gap: 0.5rem; 589 padding: 2rem 1rem; 590 color: var(--text-tertiary); 591 - font-size: 0.9rem; 592 } 593 594 .create-playlist-btn { ··· 601 border: none; 602 border-top: 1px solid var(--border-subtle); 603 color: var(--accent); 604 - font-size: 1rem; 605 font-family: inherit; 606 cursor: pointer; 607 transition: background 0.15s; ··· 625 padding: 0.75rem 1rem; 626 background: var(--bg-tertiary); 627 border: 1px solid var(--border-default); 628 - border-radius: 8px; 629 color: var(--text-primary); 630 font-family: inherit; 631 - font-size: 1rem; 632 } 633 634 .create-form input:focus { ··· 648 padding: 0.75rem 1rem; 649 background: var(--accent); 650 border: none; 651 - border-radius: 8px; 652 color: white; 653 font-family: inherit; 654 - font-size: 1rem; 655 font-weight: 500; 656 cursor: pointer; 657 transition: opacity 0.15s; ··· 671 height: 18px; 672 border: 2px solid var(--border-default); 673 border-top-color: var(--accent); 674 - border-radius: 50%; 675 animation: spin 0.8s linear infinite; 676 } 677 ··· 698 top: 50%; 699 transform: translateY(-50%); 700 margin-right: 0.5rem; 701 - border-radius: 8px; 702 min-width: 180px; 703 max-height: none; 704 animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); ··· 721 } 722 723 .menu-item span { 724 - font-size: 0.9rem; 725 } 726 727 .menu-item svg { ··· 735 736 .playlist-item { 737 padding: 0.625rem 1rem; 738 - font-size: 0.9rem; 739 } 740 741 .playlist-thumb,
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 + gated?: boolean; 14 initialLiked: boolean; 15 shareUrl: string; 16 onQueue: () => void; ··· 25 trackUri, 26 trackCid, 27 fileId, 28 + gated, 29 initialLiked, 30 shareUrl, 31 onQueue, ··· 103 104 try { 105 const success = liked 106 + ? await likeTrack(trackId, fileId, gated) 107 : await unlikeTrack(trackId); 108 109 if (!success) { ··· 402 justify-content: center; 403 background: transparent; 404 border: 1px solid var(--border-default); 405 + border-radius: var(--radius-sm); 406 color: var(--text-tertiary); 407 cursor: pointer; 408 transition: all 0.2s; ··· 474 } 475 476 .menu-item span { 477 + font-size: var(--text-lg); 478 font-weight: 400; 479 flex: 1; 480 } ··· 508 border: none; 509 border-bottom: 1px solid var(--border-default); 510 color: var(--text-secondary); 511 + font-size: var(--text-base); 512 font-family: inherit; 513 cursor: pointer; 514 transition: background 0.15s; ··· 534 border: none; 535 border-bottom: 1px solid var(--border-subtle); 536 color: var(--text-primary); 537 + font-size: var(--text-lg); 538 font-family: inherit; 539 cursor: pointer; 540 transition: background 0.15s; ··· 558 .playlist-thumb-placeholder { 559 width: 36px; 560 height: 36px; 561 + border-radius: var(--radius-sm); 562 flex-shrink: 0; 563 } 564 ··· 590 gap: 0.5rem; 591 padding: 2rem 1rem; 592 color: var(--text-tertiary); 593 + font-size: var(--text-base); 594 } 595 596 .create-playlist-btn { ··· 603 border: none; 604 border-top: 1px solid var(--border-subtle); 605 color: var(--accent); 606 + font-size: var(--text-lg); 607 font-family: inherit; 608 cursor: pointer; 609 transition: background 0.15s; ··· 627 padding: 0.75rem 1rem; 628 background: var(--bg-tertiary); 629 border: 1px solid var(--border-default); 630 + border-radius: var(--radius-md); 631 color: var(--text-primary); 632 font-family: inherit; 633 + font-size: var(--text-lg); 634 } 635 636 .create-form input:focus { ··· 650 padding: 0.75rem 1rem; 651 background: var(--accent); 652 border: none; 653 + border-radius: var(--radius-md); 654 color: white; 655 font-family: inherit; 656 + font-size: var(--text-lg); 657 font-weight: 500; 658 cursor: pointer; 659 transition: opacity 0.15s; ··· 673 height: 18px; 674 border: 2px solid var(--border-default); 675 border-top-color: var(--accent); 676 + border-radius: var(--radius-full); 677 animation: spin 0.8s linear infinite; 678 } 679 ··· 700 top: 50%; 701 transform: translateY(-50%); 702 margin-right: 0.5rem; 703 + border-radius: var(--radius-md); 704 min-width: 180px; 705 max-height: none; 706 animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); ··· 723 } 724 725 .menu-item span { 726 + font-size: var(--text-base); 727 } 728 729 .menu-item svg { ··· 737 738 .playlist-item { 739 padding: 0.625rem 1rem; 740 + font-size: var(--text-base); 741 } 742 743 .playlist-thumb,
+22 -20
frontend/src/lib/components/TrackItem.svelte
··· 306 trackUri={track.atproto_record_uri} 307 trackCid={track.atproto_record_cid} 308 fileId={track.file_id} 309 initialLiked={track.is_liked || false} 310 disabled={!track.atproto_record_uri} 311 disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined} ··· 339 trackUri={track.atproto_record_uri} 340 trackCid={track.atproto_record_cid} 341 fileId={track.file_id} 342 initialLiked={track.is_liked || false} 343 shareUrl={shareUrl} 344 onQueue={handleQueue} ··· 357 gap: 0.75rem; 358 background: var(--track-bg, var(--bg-secondary)); 359 border: 1px solid var(--track-border, var(--border-subtle)); 360 - border-radius: 8px; 361 padding: 1rem; 362 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); 363 transition: ··· 368 369 .track-index { 370 width: 24px; 371 - font-size: 0.85rem; 372 color: var(--text-muted); 373 text-align: center; 374 flex-shrink: 0; ··· 426 position: absolute; 427 inset: 0; 428 background: rgba(0, 0, 0, 0.3); 429 - border-radius: 4px; 430 pointer-events: none; 431 } 432 ··· 441 justify-content: center; 442 background: var(--accent); 443 border: 2px solid var(--bg-secondary); 444 - border-radius: 50%; 445 color: white; 446 z-index: 1; 447 } ··· 454 display: flex; 455 align-items: center; 456 justify-content: center; 457 - border-radius: 4px; 458 overflow: hidden; 459 background: var(--bg-tertiary); 460 border: 1px solid var(--border-subtle); ··· 488 } 489 490 .track-avatar img { 491 - border-radius: 50%; 492 border: 2px solid var(--border-default); 493 transition: border-color 0.2s; 494 } ··· 538 align-items: flex-start; 539 gap: 0.15rem; 540 color: var(--text-secondary); 541 - font-size: 0.9rem; 542 font-family: inherit; 543 min-width: 0; 544 width: 100%; ··· 558 559 .metadata-separator { 560 display: none; 561 - font-size: 0.7rem; 562 } 563 564 .artist-link { ··· 657 padding: 0.1rem 0.4rem; 658 background: color-mix(in srgb, var(--accent) 15%, transparent); 659 color: var(--accent-hover); 660 - border-radius: 3px; 661 - font-size: 0.75rem; 662 font-weight: 500; 663 text-decoration: none; 664 transition: all 0.15s; ··· 677 background: var(--bg-tertiary); 678 color: var(--text-muted); 679 border: none; 680 - border-radius: 3px; 681 - font-size: 0.75rem; 682 font-weight: 500; 683 font-family: inherit; 684 cursor: pointer; ··· 693 } 694 695 .track-meta { 696 - font-size: 0.8rem; 697 color: var(--text-tertiary); 698 display: flex; 699 align-items: center; ··· 707 708 .meta-separator { 709 color: var(--text-muted); 710 - font-size: 0.7rem; 711 } 712 713 .likes { ··· 754 justify-content: center; 755 background: transparent; 756 border: 1px solid var(--border-default); 757 - border-radius: 4px; 758 color: var(--text-tertiary); 759 cursor: pointer; 760 transition: all 0.2s; ··· 820 } 821 822 .track-title { 823 - font-size: 0.9rem; 824 } 825 826 .track-metadata { 827 - font-size: 0.8rem; 828 gap: 0.35rem; 829 } 830 831 .track-meta { 832 - font-size: 0.7rem; 833 } 834 835 .track-actions { ··· 873 } 874 875 .track-title { 876 - font-size: 0.85rem; 877 } 878 879 .track-metadata { 880 - font-size: 0.75rem; 881 } 882 883 .metadata-separator {
··· 306 trackUri={track.atproto_record_uri} 307 trackCid={track.atproto_record_cid} 308 fileId={track.file_id} 309 + gated={track.gated} 310 initialLiked={track.is_liked || false} 311 disabled={!track.atproto_record_uri} 312 disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined} ··· 340 trackUri={track.atproto_record_uri} 341 trackCid={track.atproto_record_cid} 342 fileId={track.file_id} 343 + gated={track.gated} 344 initialLiked={track.is_liked || false} 345 shareUrl={shareUrl} 346 onQueue={handleQueue} ··· 359 gap: 0.75rem; 360 background: var(--track-bg, var(--bg-secondary)); 361 border: 1px solid var(--track-border, var(--border-subtle)); 362 + border-radius: var(--radius-md); 363 padding: 1rem; 364 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); 365 transition: ··· 370 371 .track-index { 372 width: 24px; 373 + font-size: var(--text-sm); 374 color: var(--text-muted); 375 text-align: center; 376 flex-shrink: 0; ··· 428 position: absolute; 429 inset: 0; 430 background: rgba(0, 0, 0, 0.3); 431 + border-radius: var(--radius-sm); 432 pointer-events: none; 433 } 434 ··· 443 justify-content: center; 444 background: var(--accent); 445 border: 2px solid var(--bg-secondary); 446 + border-radius: var(--radius-full); 447 color: white; 448 z-index: 1; 449 } ··· 456 display: flex; 457 align-items: center; 458 justify-content: center; 459 + border-radius: var(--radius-sm); 460 overflow: hidden; 461 background: var(--bg-tertiary); 462 border: 1px solid var(--border-subtle); ··· 490 } 491 492 .track-avatar img { 493 + border-radius: var(--radius-full); 494 border: 2px solid var(--border-default); 495 transition: border-color 0.2s; 496 } ··· 540 align-items: flex-start; 541 gap: 0.15rem; 542 color: var(--text-secondary); 543 + font-size: var(--text-base); 544 font-family: inherit; 545 min-width: 0; 546 width: 100%; ··· 560 561 .metadata-separator { 562 display: none; 563 + font-size: var(--text-xs); 564 } 565 566 .artist-link { ··· 659 padding: 0.1rem 0.4rem; 660 background: color-mix(in srgb, var(--accent) 15%, transparent); 661 color: var(--accent-hover); 662 + border-radius: var(--radius-sm); 663 + font-size: var(--text-xs); 664 font-weight: 500; 665 text-decoration: none; 666 transition: all 0.15s; ··· 679 background: var(--bg-tertiary); 680 color: var(--text-muted); 681 border: none; 682 + border-radius: var(--radius-sm); 683 + font-size: var(--text-xs); 684 font-weight: 500; 685 font-family: inherit; 686 cursor: pointer; ··· 695 } 696 697 .track-meta { 698 + font-size: var(--text-sm); 699 color: var(--text-tertiary); 700 display: flex; 701 align-items: center; ··· 709 710 .meta-separator { 711 color: var(--text-muted); 712 + font-size: var(--text-xs); 713 } 714 715 .likes { ··· 756 justify-content: center; 757 background: transparent; 758 border: 1px solid var(--border-default); 759 + border-radius: var(--radius-sm); 760 color: var(--text-tertiary); 761 cursor: pointer; 762 transition: all 0.2s; ··· 822 } 823 824 .track-title { 825 + font-size: var(--text-base); 826 } 827 828 .track-metadata { 829 + font-size: var(--text-sm); 830 gap: 0.35rem; 831 } 832 833 .track-meta { 834 + font-size: var(--text-xs); 835 } 836 837 .track-actions { ··· 875 } 876 877 .track-title { 878 + font-size: var(--text-sm); 879 } 880 881 .track-metadata { 882 + font-size: var(--text-xs); 883 } 884 885 .metadata-separator {
+1 -1
frontend/src/lib/components/WaveLoading.svelte
··· 67 .message { 68 margin: 0; 69 color: var(--text-secondary); 70 - font-size: 0.9rem; 71 text-align: center; 72 } 73 </style>
··· 67 .message { 68 margin: 0; 69 color: var(--text-secondary); 70 + font-size: var(--text-base); 71 text-align: center; 72 } 73 </style>
+6 -6
frontend/src/lib/components/player/PlaybackControls.svelte
··· 244 align-items: center; 245 justify-content: center; 246 transition: all 0.2s; 247 - border-radius: 50%; 248 } 249 250 .control-btn svg { ··· 282 display: flex; 283 align-items: center; 284 justify-content: center; 285 - border-radius: 6px; 286 transition: all 0.2s; 287 position: relative; 288 } ··· 310 } 311 312 .time { 313 - font-size: 0.85rem; 314 color: var(--text-tertiary); 315 min-width: 45px; 316 font-variant-numeric: tabular-nums; ··· 382 background: var(--accent); 383 height: 14px; 384 width: 14px; 385 - border-radius: 50%; 386 margin-top: -5px; 387 transition: all 0.2s; 388 box-shadow: 0 0 0 8px transparent; ··· 419 background: var(--accent); 420 height: 14px; 421 width: 14px; 422 - border-radius: 50%; 423 border: none; 424 transition: all 0.2s; 425 box-shadow: 0 0 0 8px transparent; ··· 493 } 494 495 .time { 496 - font-size: 0.75rem; 497 min-width: 38px; 498 } 499
··· 244 align-items: center; 245 justify-content: center; 246 transition: all 0.2s; 247 + border-radius: var(--radius-full); 248 } 249 250 .control-btn svg { ··· 282 display: flex; 283 align-items: center; 284 justify-content: center; 285 + border-radius: var(--radius-base); 286 transition: all 0.2s; 287 position: relative; 288 } ··· 310 } 311 312 .time { 313 + font-size: var(--text-sm); 314 color: var(--text-tertiary); 315 min-width: 45px; 316 font-variant-numeric: tabular-nums; ··· 382 background: var(--accent); 383 height: 14px; 384 width: 14px; 385 + border-radius: var(--radius-full); 386 margin-top: -5px; 387 transition: all 0.2s; 388 box-shadow: 0 0 0 8px transparent; ··· 419 background: var(--accent); 420 height: 14px; 421 width: 14px; 422 + border-radius: var(--radius-full); 423 border: none; 424 transition: all 0.2s; 425 box-shadow: 0 0 0 8px transparent; ··· 493 } 494 495 .time { 496 + font-size: var(--text-xs); 497 min-width: 38px; 498 } 499
+4 -4
frontend/src/lib/components/player/TrackInfo.svelte
··· 156 flex-shrink: 0; 157 width: 56px; 158 height: 56px; 159 - border-radius: 4px; 160 overflow: hidden; 161 background: var(--bg-tertiary); 162 border: 1px solid var(--border-default); ··· 197 198 .player-title, 199 .player-title-link { 200 - font-size: 0.95rem; 201 font-weight: 600; 202 color: var(--text-primary); 203 margin-bottom: 0; ··· 384 385 .player-title, 386 .player-title-link { 387 - font-size: 0.9rem; 388 margin-bottom: 0; 389 } 390 391 .player-metadata { 392 - font-size: 0.8rem; 393 } 394 395 .player-title.scrolling,
··· 156 flex-shrink: 0; 157 width: 56px; 158 height: 56px; 159 + border-radius: var(--radius-sm); 160 overflow: hidden; 161 background: var(--bg-tertiary); 162 border: 1px solid var(--border-default); ··· 197 198 .player-title, 199 .player-title-link { 200 + font-size: var(--text-base); 201 font-weight: 600; 202 color: var(--text-primary); 203 margin-bottom: 0; ··· 384 385 .player-title, 386 .player-title-link { 387 + font-size: var(--text-base); 388 margin-bottom: 0; 389 } 390 391 .player-metadata { 392 + font-size: var(--text-sm); 393 } 394 395 .player-title.scrolling,
+4 -2
frontend/src/lib/tracks.svelte.ts
··· 133 export const tracksCache = new TracksCache(); 134 135 // like/unlike track functions 136 - export async function likeTrack(trackId: number, fileId?: string): Promise<boolean> { 137 try { 138 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 139 method: 'POST', ··· 148 tracksCache.invalidate(); 149 150 // auto-download if preference is enabled and file_id provided 151 - if (fileId && preferences.autoDownloadLiked) { 152 try { 153 const alreadyDownloaded = await isDownloaded(fileId); 154 if (!alreadyDownloaded) {
··· 133 export const tracksCache = new TracksCache(); 134 135 // like/unlike track functions 136 + // gated: true means viewer lacks access (non-supporter), false means accessible 137 + export async function likeTrack(trackId: number, fileId?: string, gated?: boolean): Promise<boolean> { 138 try { 139 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 140 method: 'POST', ··· 149 tracksCache.invalidate(); 150 151 // auto-download if preference is enabled and file_id provided 152 + // skip download only if track is gated AND viewer lacks access (gated === true) 153 + if (fileId && preferences.autoDownloadLiked && gated !== true) { 154 try { 155 const alreadyDownloaded = await isDownloaded(fileId); 156 if (!alreadyDownloaded) {
+50 -4
frontend/src/lib/uploader.svelte.ts
··· 23 onError?: (_error: string) => void; 24 } 25 26 // global upload manager using Svelte 5 runes 27 class UploaderState { 28 activeUploads = $state<Map<string, UploadTask>>(new Map()); ··· 40 ): void { 41 const taskId = crypto.randomUUID(); 42 const fileSizeMB = file.size / 1024 / 1024; 43 const uploadMessage = fileSizeMB > 10 44 ? 'uploading track... (large file, this may take a moment)' 45 : 'uploading track...'; 46 // 0 means infinite/persist until dismissed 47 const toastId = toast.info(uploadMessage, 0); 48 49 if (!browser) return; 50 const formData = new FormData(); ··· 74 xhr.upload.addEventListener('progress', (e) => { 75 if (e.lengthComputable && !uploadComplete) { 76 const percent = Math.round((e.loaded / e.total) * 100); 77 const progressMsg = `retrieving your file... ${percent}%`; 78 toast.update(toastId, progressMsg); 79 if (callbacks?.onProgress) { ··· 172 errorMsg = error.detail || errorMsg; 173 } catch { 174 if (xhr.status === 0) { 175 - errorMsg = 'network error: connection failed. check your internet connection and try again'; 176 } else if (xhr.status >= 500) { 177 errorMsg = 'server error: please try again in a moment'; 178 } else if (xhr.status === 413) { 179 errorMsg = 'file too large: please use a smaller file'; 180 } else if (xhr.status === 408 || xhr.status === 504) { 181 - errorMsg = 'upload timed out: please try again with a better connection'; 182 } 183 } 184 toast.error(errorMsg); ··· 190 191 xhr.addEventListener('error', () => { 192 toast.dismiss(toastId); 193 - const errorMsg = 'network error: connection failed. check your internet connection and try again'; 194 toast.error(errorMsg); 195 if (callbacks?.onError) { 196 callbacks.onError(errorMsg); ··· 199 200 xhr.addEventListener('timeout', () => { 201 toast.dismiss(toastId); 202 - const errorMsg = 'upload timed out: please try again with a better connection'; 203 toast.error(errorMsg); 204 if (callbacks?.onError) { 205 callbacks.onError(errorMsg);
··· 23 onError?: (_error: string) => void; 24 } 25 26 + function isMobileDevice(): boolean { 27 + if (!browser) return false; 28 + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); 29 + } 30 + 31 + const MOBILE_LARGE_FILE_THRESHOLD_MB = 50; 32 + 33 + function buildNetworkErrorMessage(progressPercent: number, fileSizeMB: number, isMobile: boolean): string { 34 + const progressInfo = progressPercent > 0 ? ` (failed at ${progressPercent}%)` : ''; 35 + 36 + if (isMobile && fileSizeMB > MOBILE_LARGE_FILE_THRESHOLD_MB) { 37 + return `upload failed${progressInfo}: large files often fail on mobile networks. try uploading from a desktop or use WiFi`; 38 + } 39 + 40 + if (progressPercent > 0 && progressPercent < 100) { 41 + return `upload failed${progressInfo}: connection was interrupted. check your network and try again`; 42 + } 43 + 44 + return `upload failed${progressInfo}: connection failed. check your internet connection and try again`; 45 + } 46 + 47 + function buildTimeoutErrorMessage(progressPercent: number, fileSizeMB: number, isMobile: boolean): string { 48 + const progressInfo = progressPercent > 0 ? ` (stopped at ${progressPercent}%)` : ''; 49 + 50 + if (isMobile) { 51 + return `upload timed out${progressInfo}: mobile uploads can be slow. try WiFi or a desktop browser`; 52 + } 53 + 54 + if (fileSizeMB > 100) { 55 + return `upload timed out${progressInfo}: large file (${Math.round(fileSizeMB)}MB) - try a faster connection`; 56 + } 57 + 58 + return `upload timed out${progressInfo}: try again with a better connection`; 59 + } 60 + 61 // global upload manager using Svelte 5 runes 62 class UploaderState { 63 activeUploads = $state<Map<string, UploadTask>>(new Map()); ··· 75 ): void { 76 const taskId = crypto.randomUUID(); 77 const fileSizeMB = file.size / 1024 / 1024; 78 + const isMobile = isMobileDevice(); 79 + 80 + // warn about large files on mobile 81 + if (isMobile && fileSizeMB > MOBILE_LARGE_FILE_THRESHOLD_MB) { 82 + toast.info(`uploading ${Math.round(fileSizeMB)}MB file on mobile - ensure stable connection`, 5000); 83 + } 84 + 85 const uploadMessage = fileSizeMB > 10 86 ? 'uploading track... (large file, this may take a moment)' 87 : 'uploading track...'; 88 // 0 means infinite/persist until dismissed 89 const toastId = toast.info(uploadMessage, 0); 90 + 91 + // track upload progress for error messages 92 + let lastProgressPercent = 0; 93 94 if (!browser) return; 95 const formData = new FormData(); ··· 119 xhr.upload.addEventListener('progress', (e) => { 120 if (e.lengthComputable && !uploadComplete) { 121 const percent = Math.round((e.loaded / e.total) * 100); 122 + lastProgressPercent = percent; 123 const progressMsg = `retrieving your file... ${percent}%`; 124 toast.update(toastId, progressMsg); 125 if (callbacks?.onProgress) { ··· 218 errorMsg = error.detail || errorMsg; 219 } catch { 220 if (xhr.status === 0) { 221 + errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 222 } else if (xhr.status >= 500) { 223 errorMsg = 'server error: please try again in a moment'; 224 } else if (xhr.status === 413) { 225 errorMsg = 'file too large: please use a smaller file'; 226 } else if (xhr.status === 408 || xhr.status === 504) { 227 + errorMsg = buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 228 } 229 } 230 toast.error(errorMsg); ··· 236 237 xhr.addEventListener('error', () => { 238 toast.dismiss(toastId); 239 + const errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 240 toast.error(errorMsg); 241 if (callbacks?.onError) { 242 callbacks.onError(errorMsg); ··· 245 246 xhr.addEventListener('timeout', () => { 247 toast.dismiss(toastId); 248 + const errorMsg = buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 249 toast.error(errorMsg); 250 if (callbacks?.onError) { 251 callbacks.onError(errorMsg);
+5 -5
frontend/src/routes/+error.svelte
··· 62 } 63 64 .error-message { 65 - font-size: 1.25rem; 66 color: var(--text-secondary); 67 margin: 0 0 0.5rem 0; 68 } 69 70 .error-detail { 71 - font-size: 1rem; 72 color: var(--text-tertiary); 73 margin: 0 0 2rem 0; 74 } ··· 76 .home-link { 77 color: var(--accent); 78 text-decoration: none; 79 - font-size: 1.1rem; 80 padding: 0.75rem 1.5rem; 81 border: 1px solid var(--accent); 82 - border-radius: 6px; 83 transition: all 0.2s; 84 } 85 ··· 98 } 99 100 .error-message { 101 - font-size: 1.1rem; 102 } 103 } 104 </style>
··· 62 } 63 64 .error-message { 65 + font-size: var(--text-2xl); 66 color: var(--text-secondary); 67 margin: 0 0 0.5rem 0; 68 } 69 70 .error-detail { 71 + font-size: var(--text-lg); 72 color: var(--text-tertiary); 73 margin: 0 0 2rem 0; 74 } ··· 76 .home-link { 77 color: var(--accent); 78 text-decoration: none; 79 + font-size: var(--text-xl); 80 padding: 0.75rem 1.5rem; 81 border: 1px solid var(--accent); 82 + border-radius: var(--radius-base); 83 transition: all 0.2s; 84 } 85 ··· 98 } 99 100 .error-message { 101 + font-size: var(--text-xl); 102 } 103 } 104 </style>
+32 -4
frontend/src/routes/+layout.svelte
··· 450 --text-muted: #666666; 451 452 /* typography scale */ 453 - --text-page-heading: 1.5rem; 454 --text-section-heading: 1.2rem; 455 - --text-body: 1rem; 456 - --text-small: 0.9rem; 457 458 /* semantic */ 459 --success: #4ade80; ··· 516 color: var(--accent-muted); 517 } 518 519 :global(body) { 520 margin: 0; 521 padding: 0; ··· 589 right: 20px; 590 width: 48px; 591 height: 48px; 592 - border-radius: 50%; 593 background: var(--bg-secondary); 594 border: 1px solid var(--border-default); 595 color: var(--text-secondary);
··· 450 --text-muted: #666666; 451 452 /* typography scale */ 453 + --text-xs: 0.75rem; 454 + --text-sm: 0.85rem; 455 + --text-base: 0.9rem; 456 + --text-lg: 1rem; 457 + --text-xl: 1.1rem; 458 + --text-2xl: 1.25rem; 459 + --text-3xl: 1.5rem; 460 + 461 + /* semantic typography (aliases) */ 462 + --text-page-heading: var(--text-3xl); 463 --text-section-heading: 1.2rem; 464 + --text-body: var(--text-lg); 465 + --text-small: var(--text-base); 466 + 467 + /* border radius scale */ 468 + --radius-sm: 4px; 469 + --radius-base: 6px; 470 + --radius-md: 8px; 471 + --radius-lg: 12px; 472 + --radius-xl: 16px; 473 + --radius-2xl: 24px; 474 + --radius-full: 9999px; 475 476 /* semantic */ 477 --success: #4ade80; ··· 534 color: var(--accent-muted); 535 } 536 537 + /* shared animation for active play buttons */ 538 + @keyframes -global-ethereal-glow { 539 + 0%, 100% { 540 + box-shadow: 0 0 8px 1px color-mix(in srgb, var(--accent) 25%, transparent); 541 + } 542 + 50% { 543 + box-shadow: 0 0 14px 3px color-mix(in srgb, var(--accent) 45%, transparent); 544 + } 545 + } 546 + 547 :global(body) { 548 margin: 0; 549 padding: 0; ··· 617 right: 20px; 618 width: 48px; 619 height: 48px; 620 + border-radius: var(--radius-full); 621 background: var(--bg-secondary); 622 border: 1px solid var(--border-default); 623 color: var(--text-secondary);
+1 -1
frontend/src/routes/+page.svelte
··· 227 } 228 229 .section-header h2 { 230 - font-size: 1.25rem; 231 } 232 } 233 </style>
··· 227 } 228 229 .section-header h2 { 230 + font-size: var(--text-2xl); 231 } 232 } 233 </style>
+146 -38
frontend/src/routes/costs/+page.svelte
··· 59 let loading = $state(true); 60 let error = $state<string | null>(null); 61 let data = $state<CostData | null>(null); 62 63 // derived values for bar chart scaling 64 let maxCost = $derived( ··· 72 : 1 73 ); 74 75 - let maxRequests = $derived( 76 - data?.costs.audd.daily.length 77 - ? Math.max(...data.costs.audd.daily.map((d) => d.requests)) 78 - : 1 79 - ); 80 81 onMount(async () => { 82 try { ··· 216 217 <!-- audd details --> 218 <section class="audd-section"> 219 - <h2>copyright scanning (audd)</h2> 220 <div class="audd-stats"> 221 <div class="stat"> 222 - <span class="stat-value">{data.costs.audd.requests_this_period.toLocaleString()}</span> 223 - <span class="stat-label">API requests</span> 224 </div> 225 <div class="stat"> 226 <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 227 <span class="stat-label">free remaining</span> 228 </div> 229 <div class="stat"> 230 - <span class="stat-value">{data.costs.audd.scans_this_period.toLocaleString()}</span> 231 <span class="stat-label">tracks scanned</span> 232 </div> 233 </div> ··· 236 1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month, 237 then ${(5).toFixed(2)}/1k requests. 238 {#if data.costs.audd.billable_requests > 0} 239 - <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this period. 240 {/if} 241 </p> 242 243 - {#if data.costs.audd.daily.length > 0} 244 <div class="daily-chart"> 245 <h3>daily requests</h3> 246 <div class="chart-bars"> 247 - {#each data.costs.audd.daily as day} 248 <div class="chart-bar-container"> 249 <div 250 class="chart-bar" ··· 256 {/each} 257 </div> 258 </div> 259 {/if} 260 </section> 261 ··· 303 304 .subtitle { 305 color: var(--text-tertiary); 306 - font-size: 0.9rem; 307 margin: 0; 308 } 309 ··· 321 322 .error-state .hint { 323 color: var(--text-tertiary); 324 - font-size: 0.85rem; 325 margin-top: 0.5rem; 326 } 327 ··· 337 padding: 2rem; 338 background: var(--bg-tertiary); 339 border: 1px solid var(--border-subtle); 340 - border-radius: 12px; 341 } 342 343 .total-label { 344 - font-size: 0.8rem; 345 text-transform: uppercase; 346 letter-spacing: 0.08em; 347 color: var(--text-tertiary); ··· 356 357 .updated { 358 text-align: center; 359 - font-size: 0.75rem; 360 color: var(--text-tertiary); 361 margin-top: 0.75rem; 362 } ··· 368 369 .breakdown-section h2, 370 .audd-section h2 { 371 - font-size: 0.8rem; 372 text-transform: uppercase; 373 letter-spacing: 0.08em; 374 color: var(--text-tertiary); ··· 384 .cost-item { 385 background: var(--bg-tertiary); 386 border: 1px solid var(--border-subtle); 387 - border-radius: 8px; 388 padding: 1rem; 389 } 390 ··· 409 .cost-bar-bg { 410 height: 8px; 411 background: var(--bg-primary); 412 - border-radius: 4px; 413 overflow: hidden; 414 margin-bottom: 0.5rem; 415 } ··· 417 .cost-bar { 418 height: 100%; 419 background: var(--accent); 420 - border-radius: 4px; 421 transition: width 0.3s ease; 422 } 423 ··· 426 } 427 428 .cost-note { 429 - font-size: 0.75rem; 430 color: var(--text-tertiary); 431 } 432 ··· 435 margin-bottom: 2rem; 436 } 437 438 .audd-stats { 439 display: grid; 440 grid-template-columns: repeat(3, 1fr); ··· 443 } 444 445 .audd-explainer { 446 - font-size: 0.8rem; 447 color: var(--text-secondary); 448 margin-bottom: 1.5rem; 449 line-height: 1.5; ··· 460 padding: 1rem; 461 background: var(--bg-tertiary); 462 border: 1px solid var(--border-subtle); 463 - border-radius: 8px; 464 } 465 466 .stat-value { 467 - font-size: 1.25rem; 468 font-weight: 700; 469 color: var(--text-primary); 470 font-variant-numeric: tabular-nums; 471 } 472 473 .stat-label { 474 - font-size: 0.7rem; 475 color: var(--text-tertiary); 476 text-align: center; 477 margin-top: 0.25rem; ··· 481 .daily-chart { 482 background: var(--bg-tertiary); 483 border: 1px solid var(--border-subtle); 484 - border-radius: 8px; 485 padding: 1rem; 486 } 487 488 .daily-chart h3 { 489 - font-size: 0.75rem; 490 text-transform: uppercase; 491 letter-spacing: 0.05em; 492 color: var(--text-tertiary); ··· 496 .chart-bars { 497 display: flex; 498 align-items: flex-end; 499 - gap: 4px; 500 height: 100px; 501 } 502 503 .chart-bar-container { 504 - flex: 1; 505 display: flex; 506 flex-direction: column; 507 align-items: center; ··· 522 } 523 524 .chart-label { 525 - font-size: 0.6rem; 526 color: var(--text-tertiary); 527 - margin-top: 0.5rem; 528 white-space: nowrap; 529 } 530 531 /* support section */ ··· 544 var(--bg-tertiary) 545 ); 546 border: 1px solid var(--border-subtle); 547 - border-radius: 12px; 548 } 549 550 .support-icon { ··· 554 555 .support-text h3 { 556 margin: 0 0 0.5rem; 557 - font-size: 1.1rem; 558 color: var(--text-primary); 559 } 560 561 .support-text p { 562 margin: 0 0 1.5rem; 563 color: var(--text-secondary); 564 - font-size: 0.9rem; 565 } 566 567 .support-button { ··· 571 padding: 0.75rem 1.5rem; 572 background: var(--accent); 573 color: white; 574 - border-radius: 8px; 575 text-decoration: none; 576 font-weight: 600; 577 - font-size: 0.9rem; 578 transition: transform 0.15s, box-shadow 0.15s; 579 } 580 ··· 586 /* footer */ 587 .footer-note { 588 text-align: center; 589 - font-size: 0.8rem; 590 color: var(--text-tertiary); 591 padding-bottom: 1rem; 592 }
··· 59 let loading = $state(true); 60 let error = $state<string | null>(null); 61 let data = $state<CostData | null>(null); 62 + let timeRange = $state<'day' | 'week' | 'month'>('month'); 63 + 64 + // filter daily data based on selected time range 65 + // returns the last N days of data based on selection 66 + let filteredDaily = $derived.by(() => { 67 + if (!data?.costs.audd.daily.length) return []; 68 + const daily = data.costs.audd.daily; 69 + if (timeRange === 'day') { 70 + // show last 2 days (today + yesterday) for 24h view 71 + return daily.slice(-2); 72 + } else if (timeRange === 'week') { 73 + // show last 7 days 74 + return daily.slice(-7); 75 + } else { 76 + // show all (up to 30 days) 77 + return daily; 78 + } 79 + }); 80 + 81 + // calculate totals for selected time range 82 + let filteredTotals = $derived.by(() => { 83 + return { 84 + requests: filteredDaily.reduce((sum, d) => sum + d.requests, 0), 85 + scans: filteredDaily.reduce((sum, d) => sum + d.scans, 0) 86 + }; 87 + }); 88 89 // derived values for bar chart scaling 90 let maxCost = $derived( ··· 98 : 1 99 ); 100 101 + let maxRequests = $derived.by(() => { 102 + return filteredDaily.length ? Math.max(...filteredDaily.map((d) => d.requests)) : 1; 103 + }); 104 105 onMount(async () => { 106 try { ··· 240 241 <!-- audd details --> 242 <section class="audd-section"> 243 + <div class="audd-header"> 244 + <h2>api requests (audd)</h2> 245 + <div class="time-range-toggle"> 246 + <button 247 + class:active={timeRange === 'day'} 248 + onclick={() => (timeRange = 'day')} 249 + > 250 + 24h 251 + </button> 252 + <button 253 + class:active={timeRange === 'week'} 254 + onclick={() => (timeRange = 'week')} 255 + > 256 + 7d 257 + </button> 258 + <button 259 + class:active={timeRange === 'month'} 260 + onclick={() => (timeRange = 'month')} 261 + > 262 + 30d 263 + </button> 264 + </div> 265 + </div> 266 + 267 <div class="audd-stats"> 268 <div class="stat"> 269 + <span class="stat-value">{filteredTotals.requests.toLocaleString()}</span> 270 + <span class="stat-label">requests ({timeRange === 'day' ? '24h' : timeRange === 'week' ? '7d' : '30d'})</span> 271 </div> 272 <div class="stat"> 273 <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 274 <span class="stat-label">free remaining</span> 275 </div> 276 <div class="stat"> 277 + <span class="stat-value">{filteredTotals.scans.toLocaleString()}</span> 278 <span class="stat-label">tracks scanned</span> 279 </div> 280 </div> ··· 283 1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month, 284 then ${(5).toFixed(2)}/1k requests. 285 {#if data.costs.audd.billable_requests > 0} 286 + <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this billing period. 287 {/if} 288 </p> 289 290 + {#if filteredDaily.length > 0} 291 <div class="daily-chart"> 292 <h3>daily requests</h3> 293 <div class="chart-bars"> 294 + {#each filteredDaily as day} 295 <div class="chart-bar-container"> 296 <div 297 class="chart-bar" ··· 303 {/each} 304 </div> 305 </div> 306 + {:else} 307 + <p class="no-data">no requests in this time range</p> 308 {/if} 309 </section> 310 ··· 352 353 .subtitle { 354 color: var(--text-tertiary); 355 + font-size: var(--text-base); 356 margin: 0; 357 } 358 ··· 370 371 .error-state .hint { 372 color: var(--text-tertiary); 373 + font-size: var(--text-sm); 374 margin-top: 0.5rem; 375 } 376 ··· 386 padding: 2rem; 387 background: var(--bg-tertiary); 388 border: 1px solid var(--border-subtle); 389 + border-radius: var(--radius-lg); 390 } 391 392 .total-label { 393 + font-size: var(--text-sm); 394 text-transform: uppercase; 395 letter-spacing: 0.08em; 396 color: var(--text-tertiary); ··· 405 406 .updated { 407 text-align: center; 408 + font-size: var(--text-xs); 409 color: var(--text-tertiary); 410 margin-top: 0.75rem; 411 } ··· 417 418 .breakdown-section h2, 419 .audd-section h2 { 420 + font-size: var(--text-sm); 421 text-transform: uppercase; 422 letter-spacing: 0.08em; 423 color: var(--text-tertiary); ··· 433 .cost-item { 434 background: var(--bg-tertiary); 435 border: 1px solid var(--border-subtle); 436 + border-radius: var(--radius-md); 437 padding: 1rem; 438 } 439 ··· 458 .cost-bar-bg { 459 height: 8px; 460 background: var(--bg-primary); 461 + border-radius: var(--radius-sm); 462 overflow: hidden; 463 margin-bottom: 0.5rem; 464 } ··· 466 .cost-bar { 467 height: 100%; 468 background: var(--accent); 469 + border-radius: var(--radius-sm); 470 transition: width 0.3s ease; 471 } 472 ··· 475 } 476 477 .cost-note { 478 + font-size: var(--text-xs); 479 color: var(--text-tertiary); 480 } 481 ··· 484 margin-bottom: 2rem; 485 } 486 487 + .audd-header { 488 + display: flex; 489 + justify-content: space-between; 490 + align-items: center; 491 + margin-bottom: 1rem; 492 + gap: 1rem; 493 + } 494 + 495 + .audd-header h2 { 496 + margin-bottom: 0; 497 + } 498 + 499 + .time-range-toggle { 500 + display: flex; 501 + gap: 0.25rem; 502 + background: var(--bg-tertiary); 503 + border: 1px solid var(--border-subtle); 504 + border-radius: var(--radius-base); 505 + padding: 0.25rem; 506 + } 507 + 508 + .time-range-toggle button { 509 + padding: 0.35rem 0.75rem; 510 + font-family: inherit; 511 + font-size: var(--text-xs); 512 + font-weight: 500; 513 + background: transparent; 514 + border: none; 515 + border-radius: var(--radius-sm); 516 + color: var(--text-secondary); 517 + cursor: pointer; 518 + transition: all 0.15s; 519 + } 520 + 521 + .time-range-toggle button:hover { 522 + color: var(--text-primary); 523 + } 524 + 525 + .time-range-toggle button.active { 526 + background: var(--accent); 527 + color: white; 528 + } 529 + 530 + .no-data { 531 + text-align: center; 532 + color: var(--text-tertiary); 533 + font-size: var(--text-sm); 534 + padding: 2rem; 535 + background: var(--bg-tertiary); 536 + border: 1px solid var(--border-subtle); 537 + border-radius: var(--radius-md); 538 + } 539 + 540 .audd-stats { 541 display: grid; 542 grid-template-columns: repeat(3, 1fr); ··· 545 } 546 547 .audd-explainer { 548 + font-size: var(--text-sm); 549 color: var(--text-secondary); 550 margin-bottom: 1.5rem; 551 line-height: 1.5; ··· 562 padding: 1rem; 563 background: var(--bg-tertiary); 564 border: 1px solid var(--border-subtle); 565 + border-radius: var(--radius-md); 566 } 567 568 .stat-value { 569 + font-size: var(--text-2xl); 570 font-weight: 700; 571 color: var(--text-primary); 572 font-variant-numeric: tabular-nums; 573 } 574 575 .stat-label { 576 + font-size: var(--text-xs); 577 color: var(--text-tertiary); 578 text-align: center; 579 margin-top: 0.25rem; ··· 583 .daily-chart { 584 background: var(--bg-tertiary); 585 border: 1px solid var(--border-subtle); 586 + border-radius: var(--radius-md); 587 padding: 1rem; 588 + overflow: hidden; 589 } 590 591 .daily-chart h3 { 592 + font-size: var(--text-xs); 593 text-transform: uppercase; 594 letter-spacing: 0.05em; 595 color: var(--text-tertiary); ··· 599 .chart-bars { 600 display: flex; 601 align-items: flex-end; 602 + gap: 2px; 603 height: 100px; 604 + width: 100%; 605 } 606 607 .chart-bar-container { 608 + flex: 1 1 0; 609 + min-width: 0; 610 display: flex; 611 flex-direction: column; 612 align-items: center; ··· 627 } 628 629 .chart-label { 630 + font-size: 0.55rem; 631 color: var(--text-tertiary); 632 + margin-top: 0.25rem; 633 white-space: nowrap; 634 + overflow: hidden; 635 + text-overflow: ellipsis; 636 + max-width: 100%; 637 } 638 639 /* support section */ ··· 652 var(--bg-tertiary) 653 ); 654 border: 1px solid var(--border-subtle); 655 + border-radius: var(--radius-lg); 656 } 657 658 .support-icon { ··· 662 663 .support-text h3 { 664 margin: 0 0 0.5rem; 665 + font-size: var(--text-xl); 666 color: var(--text-primary); 667 } 668 669 .support-text p { 670 margin: 0 0 1.5rem; 671 color: var(--text-secondary); 672 + font-size: var(--text-base); 673 } 674 675 .support-button { ··· 679 padding: 0.75rem 1.5rem; 680 background: var(--accent); 681 color: white; 682 + border-radius: var(--radius-md); 683 text-decoration: none; 684 font-weight: 600; 685 + font-size: var(--text-base); 686 transition: transform 0.15s, box-shadow 0.15s; 687 } 688 ··· 694 /* footer */ 695 .footer-note { 696 text-align: center; 697 + font-size: var(--text-sm); 698 color: var(--text-tertiary); 699 padding-bottom: 1rem; 700 }
+1 -1
frontend/src/routes/embed/track/[id]/+page.svelte
··· 184 .play-btn { 185 width: 48px; 186 height: 48px; 187 - border-radius: 50%; 188 background: #fff; 189 color: #000; 190 border: none;
··· 184 .play-btn { 185 width: 48px; 186 height: 48px; 187 + border-radius: var(--radius-full); 188 background: #fff; 189 color: #000; 190 border: none;
+25 -25
frontend/src/routes/library/+page.svelte
··· 264 } 265 266 .page-header p { 267 - font-size: 0.9rem; 268 color: var(--text-tertiary); 269 margin: 0; 270 } ··· 282 padding: 1rem 1.25rem; 283 background: var(--bg-secondary); 284 border: 1px solid var(--border-default); 285 - border-radius: 12px; 286 text-decoration: none; 287 color: inherit; 288 transition: all 0.15s; ··· 300 .collection-icon { 301 width: 48px; 302 height: 48px; 303 - border-radius: 10px; 304 display: flex; 305 align-items: center; 306 justify-content: center; ··· 320 .playlist-artwork { 321 width: 48px; 322 height: 48px; 323 - border-radius: 10px; 324 object-fit: cover; 325 flex-shrink: 0; 326 } ··· 331 } 332 333 .collection-info h3 { 334 - font-size: 1rem; 335 font-weight: 600; 336 color: var(--text-primary); 337 margin: 0 0 0.15rem 0; ··· 341 } 342 343 .collection-info p { 344 - font-size: 0.85rem; 345 color: var(--text-tertiary); 346 margin: 0; 347 } ··· 370 } 371 372 .section-header h2 { 373 - font-size: 1.1rem; 374 font-weight: 600; 375 color: var(--text-primary); 376 margin: 0; ··· 384 background: var(--accent); 385 color: white; 386 border: none; 387 - border-radius: 8px; 388 font-family: inherit; 389 font-size: 0.875rem; 390 font-weight: 500; ··· 415 padding: 3rem 2rem; 416 background: var(--bg-secondary); 417 border: 1px dashed var(--border-default); 418 - border-radius: 12px; 419 text-align: center; 420 } 421 422 .empty-icon { 423 width: 64px; 424 height: 64px; 425 - border-radius: 16px; 426 display: flex; 427 align-items: center; 428 justify-content: center; ··· 432 } 433 434 .empty-state p { 435 - font-size: 1rem; 436 font-weight: 500; 437 color: var(--text-secondary); 438 margin: 0 0 0.25rem 0; 439 } 440 441 .empty-state span { 442 - font-size: 0.85rem; 443 color: var(--text-muted); 444 } 445 ··· 461 .modal { 462 background: var(--bg-primary); 463 border: 1px solid var(--border-default); 464 - border-radius: 16px; 465 width: 100%; 466 max-width: 400px; 467 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); ··· 476 } 477 478 .modal-header h3 { 479 - font-size: 1.1rem; 480 font-weight: 600; 481 color: var(--text-primary); 482 margin: 0; ··· 490 height: 32px; 491 background: transparent; 492 border: none; 493 - border-radius: 8px; 494 color: var(--text-secondary); 495 cursor: pointer; 496 transition: all 0.15s; ··· 507 508 .modal-body label { 509 display: block; 510 - font-size: 0.85rem; 511 font-weight: 500; 512 color: var(--text-secondary); 513 margin-bottom: 0.5rem; ··· 518 padding: 0.75rem 1rem; 519 background: var(--bg-secondary); 520 border: 1px solid var(--border-default); 521 - border-radius: 8px; 522 font-family: inherit; 523 - font-size: 1rem; 524 color: var(--text-primary); 525 transition: border-color 0.15s; 526 } ··· 536 537 .modal-body .error { 538 margin: 0.5rem 0 0 0; 539 - font-size: 0.85rem; 540 color: #ef4444; 541 } 542 ··· 550 .cancel-btn, 551 .confirm-btn { 552 padding: 0.625rem 1.25rem; 553 - border-radius: 8px; 554 font-family: inherit; 555 - font-size: 0.9rem; 556 font-weight: 500; 557 cursor: pointer; 558 transition: all 0.15s; ··· 596 } 597 598 .page-header h1 { 599 - font-size: 1.5rem; 600 } 601 602 .collection-card { ··· 609 } 610 611 .collection-info h3 { 612 - font-size: 0.95rem; 613 } 614 615 .section-header h2 { 616 - font-size: 1rem; 617 } 618 619 .create-btn { 620 padding: 0.5rem 0.875rem; 621 - font-size: 0.85rem; 622 } 623 624 .empty-state {
··· 264 } 265 266 .page-header p { 267 + font-size: var(--text-base); 268 color: var(--text-tertiary); 269 margin: 0; 270 } ··· 282 padding: 1rem 1.25rem; 283 background: var(--bg-secondary); 284 border: 1px solid var(--border-default); 285 + border-radius: var(--radius-lg); 286 text-decoration: none; 287 color: inherit; 288 transition: all 0.15s; ··· 300 .collection-icon { 301 width: 48px; 302 height: 48px; 303 + border-radius: var(--radius-md); 304 display: flex; 305 align-items: center; 306 justify-content: center; ··· 320 .playlist-artwork { 321 width: 48px; 322 height: 48px; 323 + border-radius: var(--radius-md); 324 object-fit: cover; 325 flex-shrink: 0; 326 } ··· 331 } 332 333 .collection-info h3 { 334 + font-size: var(--text-lg); 335 font-weight: 600; 336 color: var(--text-primary); 337 margin: 0 0 0.15rem 0; ··· 341 } 342 343 .collection-info p { 344 + font-size: var(--text-sm); 345 color: var(--text-tertiary); 346 margin: 0; 347 } ··· 370 } 371 372 .section-header h2 { 373 + font-size: var(--text-xl); 374 font-weight: 600; 375 color: var(--text-primary); 376 margin: 0; ··· 384 background: var(--accent); 385 color: white; 386 border: none; 387 + border-radius: var(--radius-md); 388 font-family: inherit; 389 font-size: 0.875rem; 390 font-weight: 500; ··· 415 padding: 3rem 2rem; 416 background: var(--bg-secondary); 417 border: 1px dashed var(--border-default); 418 + border-radius: var(--radius-lg); 419 text-align: center; 420 } 421 422 .empty-icon { 423 width: 64px; 424 height: 64px; 425 + border-radius: var(--radius-xl); 426 display: flex; 427 align-items: center; 428 justify-content: center; ··· 432 } 433 434 .empty-state p { 435 + font-size: var(--text-lg); 436 font-weight: 500; 437 color: var(--text-secondary); 438 margin: 0 0 0.25rem 0; 439 } 440 441 .empty-state span { 442 + font-size: var(--text-sm); 443 color: var(--text-muted); 444 } 445 ··· 461 .modal { 462 background: var(--bg-primary); 463 border: 1px solid var(--border-default); 464 + border-radius: var(--radius-xl); 465 width: 100%; 466 max-width: 400px; 467 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); ··· 476 } 477 478 .modal-header h3 { 479 + font-size: var(--text-xl); 480 font-weight: 600; 481 color: var(--text-primary); 482 margin: 0; ··· 490 height: 32px; 491 background: transparent; 492 border: none; 493 + border-radius: var(--radius-md); 494 color: var(--text-secondary); 495 cursor: pointer; 496 transition: all 0.15s; ··· 507 508 .modal-body label { 509 display: block; 510 + font-size: var(--text-sm); 511 font-weight: 500; 512 color: var(--text-secondary); 513 margin-bottom: 0.5rem; ··· 518 padding: 0.75rem 1rem; 519 background: var(--bg-secondary); 520 border: 1px solid var(--border-default); 521 + border-radius: var(--radius-md); 522 font-family: inherit; 523 + font-size: var(--text-lg); 524 color: var(--text-primary); 525 transition: border-color 0.15s; 526 } ··· 536 537 .modal-body .error { 538 margin: 0.5rem 0 0 0; 539 + font-size: var(--text-sm); 540 color: #ef4444; 541 } 542 ··· 550 .cancel-btn, 551 .confirm-btn { 552 padding: 0.625rem 1.25rem; 553 + border-radius: var(--radius-md); 554 font-family: inherit; 555 + font-size: var(--text-base); 556 font-weight: 500; 557 cursor: pointer; 558 transition: all 0.15s; ··· 596 } 597 598 .page-header h1 { 599 + font-size: var(--text-3xl); 600 } 601 602 .collection-card { ··· 609 } 610 611 .collection-info h3 { 612 + font-size: var(--text-base); 613 } 614 615 .section-header h2 { 616 + font-size: var(--text-lg); 617 } 618 619 .create-btn { 620 padding: 0.5rem 0.875rem; 621 + font-size: var(--text-sm); 622 } 623 624 .empty-state {
+12 -12
frontend/src/routes/liked/+page.svelte
··· 345 } 346 347 .count { 348 - font-size: 0.85rem; 349 font-weight: 500; 350 color: var(--text-tertiary); 351 background: var(--bg-tertiary); 352 padding: 0.2rem 0.55rem; 353 - border-radius: 4px; 354 } 355 356 .header-actions { ··· 362 .queue-button, 363 .reorder-button { 364 padding: 0.75rem 1.5rem; 365 - border-radius: 24px; 366 font-weight: 600; 367 - font-size: 0.95rem; 368 font-family: inherit; 369 cursor: pointer; 370 transition: all 0.2s; ··· 419 } 420 421 .empty-state h2 { 422 - font-size: 1.5rem; 423 font-weight: 600; 424 color: var(--text-secondary); 425 margin: 0 0 0.5rem 0; 426 } 427 428 .empty-state p { 429 - font-size: 0.95rem; 430 margin: 0; 431 } 432 ··· 441 display: flex; 442 align-items: center; 443 gap: 0.5rem; 444 - border-radius: 8px; 445 transition: all 0.2s; 446 position: relative; 447 } ··· 473 color: var(--text-muted); 474 cursor: grab; 475 touch-action: none; 476 - border-radius: 4px; 477 transition: all 0.2s; 478 flex-shrink: 0; 479 } ··· 505 } 506 507 .section-header h2 { 508 - font-size: 1.25rem; 509 } 510 511 .count { 512 - font-size: 0.8rem; 513 padding: 0.15rem 0.45rem; 514 } 515 ··· 518 } 519 520 .empty-state h2 { 521 - font-size: 1.25rem; 522 } 523 524 .header-actions { ··· 528 .queue-button, 529 .reorder-button { 530 padding: 0.6rem 1rem; 531 - font-size: 0.85rem; 532 } 533 534 .queue-button svg,
··· 345 } 346 347 .count { 348 + font-size: var(--text-sm); 349 font-weight: 500; 350 color: var(--text-tertiary); 351 background: var(--bg-tertiary); 352 padding: 0.2rem 0.55rem; 353 + border-radius: var(--radius-sm); 354 } 355 356 .header-actions { ··· 362 .queue-button, 363 .reorder-button { 364 padding: 0.75rem 1.5rem; 365 + border-radius: var(--radius-2xl); 366 font-weight: 600; 367 + font-size: var(--text-base); 368 font-family: inherit; 369 cursor: pointer; 370 transition: all 0.2s; ··· 419 } 420 421 .empty-state h2 { 422 + font-size: var(--text-3xl); 423 font-weight: 600; 424 color: var(--text-secondary); 425 margin: 0 0 0.5rem 0; 426 } 427 428 .empty-state p { 429 + font-size: var(--text-base); 430 margin: 0; 431 } 432 ··· 441 display: flex; 442 align-items: center; 443 gap: 0.5rem; 444 + border-radius: var(--radius-md); 445 transition: all 0.2s; 446 position: relative; 447 } ··· 473 color: var(--text-muted); 474 cursor: grab; 475 touch-action: none; 476 + border-radius: var(--radius-sm); 477 transition: all 0.2s; 478 flex-shrink: 0; 479 } ··· 505 } 506 507 .section-header h2 { 508 + font-size: var(--text-2xl); 509 } 510 511 .count { 512 + font-size: var(--text-sm); 513 padding: 0.15rem 0.45rem; 514 } 515 ··· 518 } 519 520 .empty-state h2 { 521 + font-size: var(--text-2xl); 522 } 523 524 .header-actions { ··· 528 .queue-button, 529 .reorder-button { 530 padding: 0.6rem 1rem; 531 + font-size: var(--text-sm); 532 } 533 534 .queue-button svg,
+16 -16
frontend/src/routes/liked/[handle]/+page.svelte
··· 126 .avatar { 127 width: 64px; 128 height: 64px; 129 - border-radius: 50%; 130 object-fit: cover; 131 flex-shrink: 0; 132 } ··· 137 justify-content: center; 138 background: var(--bg-tertiary); 139 color: var(--text-secondary); 140 - font-size: 1.5rem; 141 font-weight: 600; 142 } 143 ··· 149 } 150 151 .user-info h1 { 152 - font-size: 1.5rem; 153 font-weight: 700; 154 color: var(--text-primary); 155 margin: 0; ··· 159 } 160 161 .handle { 162 - font-size: 0.9rem; 163 color: var(--text-tertiary); 164 text-decoration: none; 165 transition: color 0.15s; ··· 189 } 190 191 .count { 192 - font-size: 0.95rem; 193 font-weight: 500; 194 color: var(--text-secondary); 195 } ··· 208 background: transparent; 209 border: 1px solid var(--border-default); 210 color: var(--text-secondary); 211 - border-radius: 6px; 212 - font-size: 0.85rem; 213 font-family: inherit; 214 cursor: pointer; 215 transition: all 0.15s; ··· 241 } 242 243 .empty-state h2 { 244 - font-size: 1.5rem; 245 font-weight: 600; 246 color: var(--text-secondary); 247 margin: 0 0 0.5rem 0; 248 } 249 250 .empty-state p { 251 - font-size: 0.95rem; 252 margin: 0; 253 } 254 ··· 275 } 276 277 .avatar-placeholder { 278 - font-size: 1.25rem; 279 } 280 281 .user-info h1 { 282 - font-size: 1.25rem; 283 } 284 285 .handle { 286 - font-size: 0.85rem; 287 } 288 289 .section-header h2 { 290 - font-size: 1.25rem; 291 } 292 293 .count { 294 - font-size: 0.85rem; 295 } 296 297 .empty-state { ··· 299 } 300 301 .empty-state h2 { 302 - font-size: 1.25rem; 303 } 304 305 .btn-action { 306 padding: 0.45rem 0.7rem; 307 - font-size: 0.8rem; 308 } 309 310 .btn-action svg {
··· 126 .avatar { 127 width: 64px; 128 height: 64px; 129 + border-radius: var(--radius-full); 130 object-fit: cover; 131 flex-shrink: 0; 132 } ··· 137 justify-content: center; 138 background: var(--bg-tertiary); 139 color: var(--text-secondary); 140 + font-size: var(--text-3xl); 141 font-weight: 600; 142 } 143 ··· 149 } 150 151 .user-info h1 { 152 + font-size: var(--text-3xl); 153 font-weight: 700; 154 color: var(--text-primary); 155 margin: 0; ··· 159 } 160 161 .handle { 162 + font-size: var(--text-base); 163 color: var(--text-tertiary); 164 text-decoration: none; 165 transition: color 0.15s; ··· 189 } 190 191 .count { 192 + font-size: var(--text-base); 193 font-weight: 500; 194 color: var(--text-secondary); 195 } ··· 208 background: transparent; 209 border: 1px solid var(--border-default); 210 color: var(--text-secondary); 211 + border-radius: var(--radius-base); 212 + font-size: var(--text-sm); 213 font-family: inherit; 214 cursor: pointer; 215 transition: all 0.15s; ··· 241 } 242 243 .empty-state h2 { 244 + font-size: var(--text-3xl); 245 font-weight: 600; 246 color: var(--text-secondary); 247 margin: 0 0 0.5rem 0; 248 } 249 250 .empty-state p { 251 + font-size: var(--text-base); 252 margin: 0; 253 } 254 ··· 275 } 276 277 .avatar-placeholder { 278 + font-size: var(--text-2xl); 279 } 280 281 .user-info h1 { 282 + font-size: var(--text-2xl); 283 } 284 285 .handle { 286 + font-size: var(--text-sm); 287 } 288 289 .section-header h2 { 290 + font-size: var(--text-2xl); 291 } 292 293 .count { 294 + font-size: var(--text-sm); 295 } 296 297 .empty-state { ··· 299 } 300 301 .empty-state h2 { 302 + font-size: var(--text-2xl); 303 } 304 305 .btn-action { 306 padding: 0.45rem 0.7rem; 307 + font-size: var(--text-sm); 308 } 309 310 .btn-action svg {
+8 -8
frontend/src/routes/login/+page.svelte
··· 142 .login-card { 143 background: var(--bg-tertiary); 144 border: 1px solid var(--border-subtle); 145 - border-radius: 12px; 146 padding: 2.5rem; 147 max-width: 420px; 148 width: 100%; ··· 171 172 label { 173 color: var(--text-secondary); 174 - font-size: 0.9rem; 175 } 176 177 button.primary { ··· 180 background: var(--accent); 181 color: white; 182 border: none; 183 - border-radius: 8px; 184 - font-size: 0.95rem; 185 font-weight: 500; 186 font-family: inherit; 187 cursor: pointer; ··· 213 border: none; 214 color: var(--text-secondary); 215 font-family: inherit; 216 - font-size: 0.9rem; 217 cursor: pointer; 218 text-align: left; 219 } ··· 234 .faq-content { 235 padding: 0 0 1rem 0; 236 color: var(--text-tertiary); 237 - font-size: 0.85rem; 238 line-height: 1.6; 239 } 240 ··· 259 .faq-content code { 260 background: var(--bg-secondary); 261 padding: 0.15rem 0.4rem; 262 - border-radius: 4px; 263 font-size: 0.85em; 264 } 265 ··· 269 } 270 271 h1 { 272 - font-size: 1.5rem; 273 } 274 } 275 </style>
··· 142 .login-card { 143 background: var(--bg-tertiary); 144 border: 1px solid var(--border-subtle); 145 + border-radius: var(--radius-lg); 146 padding: 2.5rem; 147 max-width: 420px; 148 width: 100%; ··· 171 172 label { 173 color: var(--text-secondary); 174 + font-size: var(--text-base); 175 } 176 177 button.primary { ··· 180 background: var(--accent); 181 color: white; 182 border: none; 183 + border-radius: var(--radius-md); 184 + font-size: var(--text-base); 185 font-weight: 500; 186 font-family: inherit; 187 cursor: pointer; ··· 213 border: none; 214 color: var(--text-secondary); 215 font-family: inherit; 216 + font-size: var(--text-base); 217 cursor: pointer; 218 text-align: left; 219 } ··· 234 .faq-content { 235 padding: 0 0 1rem 0; 236 color: var(--text-tertiary); 237 + font-size: var(--text-sm); 238 line-height: 1.6; 239 } 240 ··· 259 .faq-content code { 260 background: var(--bg-secondary); 261 padding: 0.15rem 0.4rem; 262 + border-radius: var(--radius-sm); 263 font-size: 0.85em; 264 } 265 ··· 269 } 270 271 h1 { 272 + font-size: var(--text-3xl); 273 } 274 } 275 </style>
+66 -47
frontend/src/routes/playlist/[id]/+page.svelte
··· 607 608 // check if user owns this playlist 609 const isOwner = $derived(auth.user?.did === playlist.owner_did); 610 </script> 611 612 <svelte:window on:keydown={handleKeydown} /> ··· 865 </div> 866 867 <div class="playlist-actions"> 868 - <button class="play-button" onclick={playNow}> 869 - <svg 870 - width="20" 871 - height="20" 872 - viewBox="0 0 24 24" 873 - fill="currentColor" 874 - > 875 - <path d="M8 5v14l11-7z" /> 876 - </svg> 877 - play now 878 </button> 879 <button class="queue-button" onclick={addToQueue}> 880 <svg ··· 1341 .playlist-art { 1342 width: 200px; 1343 height: 200px; 1344 - border-radius: 8px; 1345 object-fit: cover; 1346 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 1347 } ··· 1349 .playlist-art-placeholder { 1350 width: 200px; 1351 height: 200px; 1352 - border-radius: 8px; 1353 background: var(--bg-tertiary); 1354 border: 1px solid var(--border-subtle); 1355 display: flex; ··· 1394 opacity: 0; 1395 transition: opacity 0.2s; 1396 pointer-events: none; 1397 - border-radius: 8px; 1398 font-family: inherit; 1399 } 1400 1401 .art-edit-overlay span { 1402 font-family: inherit; 1403 - font-size: 0.85rem; 1404 font-weight: 500; 1405 } 1406 ··· 1432 1433 .playlist-type { 1434 text-transform: uppercase; 1435 - font-size: 0.75rem; 1436 font-weight: 600; 1437 letter-spacing: 0.1em; 1438 color: var(--text-tertiary); ··· 1473 display: flex; 1474 align-items: center; 1475 gap: 0.75rem; 1476 - font-size: 0.95rem; 1477 color: var(--text-secondary); 1478 } 1479 ··· 1490 1491 .meta-separator { 1492 color: var(--text-muted); 1493 - font-size: 0.7rem; 1494 } 1495 1496 .show-on-profile-toggle { ··· 1499 gap: 0.5rem; 1500 margin-top: 0.75rem; 1501 cursor: pointer; 1502 - font-size: 0.85rem; 1503 color: var(--text-secondary); 1504 } 1505 ··· 1526 height: 32px; 1527 background: var(--glass-btn-bg, rgba(18, 18, 18, 0.75)); 1528 border: 1px solid var(--glass-btn-border, rgba(255, 255, 255, 0.1)); 1529 - border-radius: 6px; 1530 color: var(--text-secondary); 1531 cursor: pointer; 1532 transition: all 0.15s; ··· 1560 .play-button, 1561 .queue-button { 1562 padding: 0.75rem 1.5rem; 1563 - border-radius: 24px; 1564 font-weight: 600; 1565 - font-size: 0.95rem; 1566 font-family: inherit; 1567 cursor: pointer; 1568 transition: all 0.2s; ··· 1581 transform: scale(1.05); 1582 } 1583 1584 .queue-button { 1585 background: var(--glass-btn-bg, transparent); 1586 color: var(--text-primary); ··· 1615 } 1616 1617 .section-heading { 1618 - font-size: 1.25rem; 1619 font-weight: 600; 1620 color: var(--text-primary); 1621 margin-bottom: 1rem; ··· 1634 display: flex; 1635 align-items: center; 1636 gap: 0.5rem; 1637 - border-radius: 8px; 1638 transition: all 0.2s; 1639 position: relative; 1640 } ··· 1666 color: var(--text-muted); 1667 cursor: grab; 1668 touch-action: none; 1669 - border-radius: 4px; 1670 transition: all 0.2s; 1671 flex-shrink: 0; 1672 } ··· 1699 padding: 0.5rem; 1700 background: transparent; 1701 border: 1px solid var(--border-default); 1702 - border-radius: 4px; 1703 color: var(--text-muted); 1704 cursor: pointer; 1705 transition: all 0.2s; ··· 1732 margin-top: 0.5rem; 1733 background: transparent; 1734 border: 1px dashed var(--border-default); 1735 - border-radius: 8px; 1736 color: var(--text-tertiary); 1737 font-family: inherit; 1738 - font-size: 0.9rem; 1739 cursor: pointer; 1740 transition: all 0.2s; 1741 } ··· 1759 .empty-icon { 1760 width: 64px; 1761 height: 64px; 1762 - border-radius: 16px; 1763 display: flex; 1764 align-items: center; 1765 justify-content: center; ··· 1769 } 1770 1771 .empty-state p { 1772 - font-size: 1rem; 1773 font-weight: 500; 1774 color: var(--text-secondary); 1775 margin: 0 0 0.25rem 0; 1776 } 1777 1778 .empty-state span { 1779 - font-size: 0.85rem; 1780 color: var(--text-muted); 1781 margin-bottom: 1.5rem; 1782 } ··· 1786 background: var(--accent); 1787 color: white; 1788 border: none; 1789 - border-radius: 8px; 1790 font-family: inherit; 1791 - font-size: 0.9rem; 1792 font-weight: 500; 1793 cursor: pointer; 1794 transition: all 0.15s; ··· 1816 .modal { 1817 background: var(--bg-primary); 1818 border: 1px solid var(--border-default); 1819 - border-radius: 16px; 1820 width: 100%; 1821 max-width: 400px; 1822 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); ··· 1838 } 1839 1840 .modal-header h3 { 1841 - font-size: 1.1rem; 1842 font-weight: 600; 1843 color: var(--text-primary); 1844 margin: 0; ··· 1852 height: 32px; 1853 background: transparent; 1854 border: none; 1855 - border-radius: 8px; 1856 color: var(--text-secondary); 1857 cursor: pointer; 1858 transition: all 0.15s; ··· 1877 background: transparent; 1878 border: none; 1879 font-family: inherit; 1880 - font-size: 1rem; 1881 color: var(--text-primary); 1882 outline: none; 1883 } ··· 1898 padding: 2rem 1.5rem; 1899 text-align: center; 1900 color: var(--text-muted); 1901 - font-size: 0.9rem; 1902 margin: 0; 1903 } 1904 ··· 1922 .result-image-placeholder { 1923 width: 40px; 1924 height: 40px; 1925 - border-radius: 6px; 1926 flex-shrink: 0; 1927 } 1928 ··· 1947 } 1948 1949 .result-title { 1950 - font-size: 0.9rem; 1951 font-weight: 500; 1952 color: var(--text-primary); 1953 white-space: nowrap; ··· 1956 } 1957 1958 .result-artist { 1959 - font-size: 0.8rem; 1960 color: var(--text-tertiary); 1961 white-space: nowrap; 1962 overflow: hidden; ··· 1971 height: 36px; 1972 background: var(--accent); 1973 border: none; 1974 - border-radius: 8px; 1975 color: white; 1976 cursor: pointer; 1977 transition: all 0.15s; ··· 1994 .modal-body p { 1995 margin: 0; 1996 color: var(--text-secondary); 1997 - font-size: 0.95rem; 1998 line-height: 1.5; 1999 } 2000 ··· 2008 .cancel-btn, 2009 .confirm-btn { 2010 padding: 0.625rem 1.25rem; 2011 - border-radius: 8px; 2012 font-family: inherit; 2013 - font-size: 0.9rem; 2014 font-weight: 500; 2015 cursor: pointer; 2016 transition: all 0.15s; ··· 2053 height: 16px; 2054 border: 2px solid currentColor; 2055 border-top-color: transparent; 2056 - border-radius: 50%; 2057 animation: spin 0.6s linear infinite; 2058 } 2059 ··· 2099 } 2100 2101 .playlist-meta { 2102 - font-size: 0.85rem; 2103 } 2104 2105 .playlist-actions { ··· 2142 } 2143 2144 .playlist-meta { 2145 - font-size: 0.8rem; 2146 flex-wrap: wrap; 2147 } 2148 }
··· 607 608 // check if user owns this playlist 609 const isOwner = $derived(auth.user?.did === playlist.owner_did); 610 + 611 + // check if current track is from this playlist (active, regardless of paused state) 612 + const isPlaylistActive = $derived( 613 + player.currentTrack !== null && 614 + tracks.some(t => t.id === player.currentTrack?.id) 615 + ); 616 + 617 + // check if actively playing (not paused) 618 + const isPlaylistPlaying = $derived(isPlaylistActive && !player.paused); 619 </script> 620 621 <svelte:window on:keydown={handleKeydown} /> ··· 874 </div> 875 876 <div class="playlist-actions"> 877 + <button 878 + class="play-button" 879 + class:is-playing={isPlaylistPlaying} 880 + onclick={() => isPlaylistActive ? player.togglePlayPause() : playNow()} 881 + > 882 + {#if isPlaylistPlaying} 883 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 884 + <path d="M6 4h4v16H6zM14 4h4v16h-4z"/> 885 + </svg> 886 + pause 887 + {:else} 888 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 889 + <path d="M8 5v14l11-7z" /> 890 + </svg> 891 + play 892 + {/if} 893 </button> 894 <button class="queue-button" onclick={addToQueue}> 895 <svg ··· 1356 .playlist-art { 1357 width: 200px; 1358 height: 200px; 1359 + border-radius: var(--radius-md); 1360 object-fit: cover; 1361 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 1362 } ··· 1364 .playlist-art-placeholder { 1365 width: 200px; 1366 height: 200px; 1367 + border-radius: var(--radius-md); 1368 background: var(--bg-tertiary); 1369 border: 1px solid var(--border-subtle); 1370 display: flex; ··· 1409 opacity: 0; 1410 transition: opacity 0.2s; 1411 pointer-events: none; 1412 + border-radius: var(--radius-md); 1413 font-family: inherit; 1414 } 1415 1416 .art-edit-overlay span { 1417 font-family: inherit; 1418 + font-size: var(--text-sm); 1419 font-weight: 500; 1420 } 1421 ··· 1447 1448 .playlist-type { 1449 text-transform: uppercase; 1450 + font-size: var(--text-xs); 1451 font-weight: 600; 1452 letter-spacing: 0.1em; 1453 color: var(--text-tertiary); ··· 1488 display: flex; 1489 align-items: center; 1490 gap: 0.75rem; 1491 + font-size: var(--text-base); 1492 color: var(--text-secondary); 1493 } 1494 ··· 1505 1506 .meta-separator { 1507 color: var(--text-muted); 1508 + font-size: var(--text-xs); 1509 } 1510 1511 .show-on-profile-toggle { ··· 1514 gap: 0.5rem; 1515 margin-top: 0.75rem; 1516 cursor: pointer; 1517 + font-size: var(--text-sm); 1518 color: var(--text-secondary); 1519 } 1520 ··· 1541 height: 32px; 1542 background: var(--glass-btn-bg, rgba(18, 18, 18, 0.75)); 1543 border: 1px solid var(--glass-btn-border, rgba(255, 255, 255, 0.1)); 1544 + border-radius: var(--radius-base); 1545 color: var(--text-secondary); 1546 cursor: pointer; 1547 transition: all 0.15s; ··· 1575 .play-button, 1576 .queue-button { 1577 padding: 0.75rem 1.5rem; 1578 + border-radius: var(--radius-2xl); 1579 font-weight: 600; 1580 + font-size: var(--text-base); 1581 font-family: inherit; 1582 cursor: pointer; 1583 transition: all 0.2s; ··· 1596 transform: scale(1.05); 1597 } 1598 1599 + .play-button.is-playing { 1600 + animation: ethereal-glow 3s ease-in-out infinite; 1601 + } 1602 + 1603 .queue-button { 1604 background: var(--glass-btn-bg, transparent); 1605 color: var(--text-primary); ··· 1634 } 1635 1636 .section-heading { 1637 + font-size: var(--text-2xl); 1638 font-weight: 600; 1639 color: var(--text-primary); 1640 margin-bottom: 1rem; ··· 1653 display: flex; 1654 align-items: center; 1655 gap: 0.5rem; 1656 + border-radius: var(--radius-md); 1657 transition: all 0.2s; 1658 position: relative; 1659 } ··· 1685 color: var(--text-muted); 1686 cursor: grab; 1687 touch-action: none; 1688 + border-radius: var(--radius-sm); 1689 transition: all 0.2s; 1690 flex-shrink: 0; 1691 } ··· 1718 padding: 0.5rem; 1719 background: transparent; 1720 border: 1px solid var(--border-default); 1721 + border-radius: var(--radius-sm); 1722 color: var(--text-muted); 1723 cursor: pointer; 1724 transition: all 0.2s; ··· 1751 margin-top: 0.5rem; 1752 background: transparent; 1753 border: 1px dashed var(--border-default); 1754 + border-radius: var(--radius-md); 1755 color: var(--text-tertiary); 1756 font-family: inherit; 1757 + font-size: var(--text-base); 1758 cursor: pointer; 1759 transition: all 0.2s; 1760 } ··· 1778 .empty-icon { 1779 width: 64px; 1780 height: 64px; 1781 + border-radius: var(--radius-xl); 1782 display: flex; 1783 align-items: center; 1784 justify-content: center; ··· 1788 } 1789 1790 .empty-state p { 1791 + font-size: var(--text-lg); 1792 font-weight: 500; 1793 color: var(--text-secondary); 1794 margin: 0 0 0.25rem 0; 1795 } 1796 1797 .empty-state span { 1798 + font-size: var(--text-sm); 1799 color: var(--text-muted); 1800 margin-bottom: 1.5rem; 1801 } ··· 1805 background: var(--accent); 1806 color: white; 1807 border: none; 1808 + border-radius: var(--radius-md); 1809 font-family: inherit; 1810 + font-size: var(--text-base); 1811 font-weight: 500; 1812 cursor: pointer; 1813 transition: all 0.15s; ··· 1835 .modal { 1836 background: var(--bg-primary); 1837 border: 1px solid var(--border-default); 1838 + border-radius: var(--radius-xl); 1839 width: 100%; 1840 max-width: 400px; 1841 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); ··· 1857 } 1858 1859 .modal-header h3 { 1860 + font-size: var(--text-xl); 1861 font-weight: 600; 1862 color: var(--text-primary); 1863 margin: 0; ··· 1871 height: 32px; 1872 background: transparent; 1873 border: none; 1874 + border-radius: var(--radius-md); 1875 color: var(--text-secondary); 1876 cursor: pointer; 1877 transition: all 0.15s; ··· 1896 background: transparent; 1897 border: none; 1898 font-family: inherit; 1899 + font-size: var(--text-lg); 1900 color: var(--text-primary); 1901 outline: none; 1902 } ··· 1917 padding: 2rem 1.5rem; 1918 text-align: center; 1919 color: var(--text-muted); 1920 + font-size: var(--text-base); 1921 margin: 0; 1922 } 1923 ··· 1941 .result-image-placeholder { 1942 width: 40px; 1943 height: 40px; 1944 + border-radius: var(--radius-base); 1945 flex-shrink: 0; 1946 } 1947 ··· 1966 } 1967 1968 .result-title { 1969 + font-size: var(--text-base); 1970 font-weight: 500; 1971 color: var(--text-primary); 1972 white-space: nowrap; ··· 1975 } 1976 1977 .result-artist { 1978 + font-size: var(--text-sm); 1979 color: var(--text-tertiary); 1980 white-space: nowrap; 1981 overflow: hidden; ··· 1990 height: 36px; 1991 background: var(--accent); 1992 border: none; 1993 + border-radius: var(--radius-md); 1994 color: white; 1995 cursor: pointer; 1996 transition: all 0.15s; ··· 2013 .modal-body p { 2014 margin: 0; 2015 color: var(--text-secondary); 2016 + font-size: var(--text-base); 2017 line-height: 1.5; 2018 } 2019 ··· 2027 .cancel-btn, 2028 .confirm-btn { 2029 padding: 0.625rem 1.25rem; 2030 + border-radius: var(--radius-md); 2031 font-family: inherit; 2032 + font-size: var(--text-base); 2033 font-weight: 500; 2034 cursor: pointer; 2035 transition: all 0.15s; ··· 2072 height: 16px; 2073 border: 2px solid currentColor; 2074 border-top-color: transparent; 2075 + border-radius: var(--radius-full); 2076 animation: spin 0.6s linear infinite; 2077 } 2078 ··· 2118 } 2119 2120 .playlist-meta { 2121 + font-size: var(--text-sm); 2122 } 2123 2124 .playlist-actions { ··· 2161 } 2162 2163 .playlist-meta { 2164 + font-size: var(--text-sm); 2165 flex-wrap: wrap; 2166 } 2167 }
+113 -124
frontend/src/routes/portal/+page.svelte
··· 106 } 107 108 try { 109 - await loadMyTracks(); 110 - await loadArtistProfile(); 111 - await loadMyAlbums(); 112 - await loadMyPlaylists(); 113 } catch (_e) { 114 console.error('error loading portal data:', _e); 115 error = 'failed to load portal data'; ··· 1142 .view-profile-link { 1143 color: var(--text-secondary); 1144 text-decoration: none; 1145 - font-size: 0.8rem; 1146 padding: 0.35rem 0.6rem; 1147 background: var(--bg-tertiary); 1148 - border-radius: 5px; 1149 border: 1px solid var(--border-default); 1150 transition: all 0.15s; 1151 white-space: nowrap; ··· 1160 .settings-link { 1161 color: var(--text-secondary); 1162 text-decoration: none; 1163 - font-size: 0.8rem; 1164 padding: 0.35rem 0.6rem; 1165 background: var(--bg-tertiary); 1166 - border-radius: 5px; 1167 border: 1px solid var(--border-default); 1168 transition: all 0.15s; 1169 white-space: nowrap; ··· 1183 padding: 1rem 1.25rem; 1184 background: var(--bg-tertiary); 1185 border: 1px solid var(--border-default); 1186 - border-radius: 8px; 1187 text-decoration: none; 1188 color: var(--text-primary); 1189 transition: all 0.15s; ··· 1206 width: 44px; 1207 height: 44px; 1208 background: color-mix(in srgb, var(--accent) 15%, transparent); 1209 - border-radius: 10px; 1210 color: var(--accent); 1211 flex-shrink: 0; 1212 } ··· 1219 .upload-card-title { 1220 display: block; 1221 font-weight: 600; 1222 - font-size: 0.95rem; 1223 color: var(--text-primary); 1224 } 1225 1226 .upload-card-subtitle { 1227 display: block; 1228 - font-size: 0.8rem; 1229 color: var(--text-tertiary); 1230 } 1231 ··· 1243 form { 1244 background: var(--bg-tertiary); 1245 padding: 1.25rem; 1246 - border-radius: 8px; 1247 border: 1px solid var(--border-subtle); 1248 } 1249 ··· 1259 display: block; 1260 color: var(--text-secondary); 1261 margin-bottom: 0.4rem; 1262 - font-size: 0.85rem; 1263 } 1264 1265 - input[type='text'] { 1266 width: 100%; 1267 padding: 0.6rem 0.75rem; 1268 background: var(--bg-primary); 1269 border: 1px solid var(--border-default); 1270 - border-radius: 4px; 1271 color: var(--text-primary); 1272 - font-size: 0.95rem; 1273 font-family: inherit; 1274 transition: all 0.15s; 1275 } 1276 1277 - input[type='text']:focus { 1278 outline: none; 1279 border-color: var(--accent); 1280 } 1281 1282 - input[type='text']:disabled { 1283 opacity: 0.5; 1284 cursor: not-allowed; 1285 } 1286 1287 textarea { 1288 - width: 100%; 1289 - padding: 0.6rem 0.75rem; 1290 - background: var(--bg-primary); 1291 - border: 1px solid var(--border-default); 1292 - border-radius: 4px; 1293 - color: var(--text-primary); 1294 - font-size: 0.95rem; 1295 - font-family: inherit; 1296 - transition: all 0.15s; 1297 resize: vertical; 1298 min-height: 80px; 1299 - } 1300 - 1301 - textarea:focus { 1302 - outline: none; 1303 - border-color: var(--accent); 1304 - } 1305 - 1306 - textarea:disabled { 1307 - opacity: 0.5; 1308 - cursor: not-allowed; 1309 } 1310 1311 .hint { 1312 margin-top: 0.35rem; 1313 - font-size: 0.75rem; 1314 color: var(--text-muted); 1315 } 1316 ··· 1328 display: block; 1329 color: var(--text-secondary); 1330 margin-bottom: 0.6rem; 1331 - font-size: 0.85rem; 1332 } 1333 1334 .support-options { ··· 1345 padding: 0.6rem 0.75rem; 1346 background: var(--bg-primary); 1347 border: 1px solid var(--border-default); 1348 - border-radius: 6px; 1349 cursor: pointer; 1350 transition: all 0.15s; 1351 margin-bottom: 0; ··· 1368 } 1369 1370 .support-option span { 1371 - font-size: 0.9rem; 1372 color: var(--text-primary); 1373 } 1374 1375 .support-status { 1376 margin-left: auto; 1377 - font-size: 0.75rem; 1378 color: var(--text-tertiary); 1379 } 1380 1381 .support-setup-link, 1382 .support-status-link { 1383 margin-left: auto; 1384 - font-size: 0.75rem; 1385 text-decoration: none; 1386 } 1387 ··· 1412 padding: 0.6rem 0.75rem; 1413 background: var(--bg-primary); 1414 border: 1px solid var(--border-default); 1415 - border-radius: 4px; 1416 color: var(--text-primary); 1417 - font-size: 0.95rem; 1418 font-family: inherit; 1419 transition: all 0.15s; 1420 margin-bottom: 0.5rem; ··· 1440 .avatar-preview img { 1441 width: 64px; 1442 height: 64px; 1443 - border-radius: 50%; 1444 object-fit: cover; 1445 border: 2px solid var(--border-default); 1446 } ··· 1450 padding: 0.75rem; 1451 background: var(--bg-primary); 1452 border: 1px solid var(--border-default); 1453 - border-radius: 4px; 1454 color: var(--text-primary); 1455 - font-size: 0.9rem; 1456 font-family: inherit; 1457 cursor: pointer; 1458 } ··· 1464 1465 .file-info { 1466 margin-top: 0.5rem; 1467 - font-size: 0.85rem; 1468 color: var(--text-muted); 1469 } 1470 ··· 1474 background: var(--accent); 1475 color: var(--text-primary); 1476 border: none; 1477 - border-radius: 4px; 1478 - font-size: 1rem; 1479 font-weight: 600; 1480 font-family: inherit; 1481 cursor: pointer; ··· 1512 padding: 2rem; 1513 text-align: center; 1514 background: var(--bg-tertiary); 1515 - border-radius: 8px; 1516 border: 1px solid var(--border-subtle); 1517 } 1518 ··· 1529 gap: 1rem; 1530 background: var(--bg-tertiary); 1531 border: 1px solid var(--border-subtle); 1532 - border-radius: 6px; 1533 padding: 1rem; 1534 transition: all 0.2s; 1535 } ··· 1564 .track-artwork { 1565 width: 48px; 1566 height: 48px; 1567 - border-radius: 4px; 1568 overflow: hidden; 1569 background: var(--bg-primary); 1570 border: 1px solid var(--border-subtle); ··· 1586 } 1587 1588 .track-view-link { 1589 - font-size: 0.7rem; 1590 color: var(--text-muted); 1591 text-decoration: none; 1592 transition: color 0.15s; ··· 1633 } 1634 1635 .edit-label { 1636 - font-size: 0.85rem; 1637 color: var(--text-secondary); 1638 } 1639 ··· 1642 align-items: center; 1643 gap: 0.5rem; 1644 cursor: pointer; 1645 - font-size: 0.9rem; 1646 color: var(--text-primary); 1647 } 1648 ··· 1653 } 1654 1655 .field-hint { 1656 - font-size: 0.8rem; 1657 color: var(--text-tertiary); 1658 margin-top: 0.25rem; 1659 } ··· 1669 1670 .track-title { 1671 font-weight: 600; 1672 - font-size: 1rem; 1673 margin-bottom: 0.25rem; 1674 color: var(--text-primary); 1675 display: flex; ··· 1705 } 1706 1707 .track-meta { 1708 - font-size: 0.9rem; 1709 color: var(--text-secondary); 1710 margin-bottom: 0.25rem; 1711 display: flex; ··· 1773 padding: 0.1rem 0.4rem; 1774 background: color-mix(in srgb, var(--accent) 15%, transparent); 1775 color: var(--accent-hover); 1776 - border-radius: 3px; 1777 - font-size: 0.8rem; 1778 font-weight: 500; 1779 text-decoration: none; 1780 transition: all 0.15s; ··· 1786 } 1787 1788 .track-date { 1789 - font-size: 0.85rem; 1790 color: var(--text-muted); 1791 } 1792 ··· 1807 padding: 0; 1808 background: transparent; 1809 border: 1px solid var(--border-default); 1810 - border-radius: 6px; 1811 color: var(--text-tertiary); 1812 cursor: pointer; 1813 transition: all 0.15s; ··· 1852 padding: 0.5rem; 1853 background: var(--bg-primary); 1854 border: 1px solid var(--border-default); 1855 - border-radius: 4px; 1856 color: var(--text-primary); 1857 - font-size: 0.9rem; 1858 font-family: inherit; 1859 } 1860 ··· 1865 padding: 0.5rem; 1866 background: var(--bg-primary); 1867 border: 1px solid var(--border-default); 1868 - border-radius: 4px; 1869 margin-bottom: 0.5rem; 1870 } 1871 1872 .current-image-preview img { 1873 width: 48px; 1874 height: 48px; 1875 - border-radius: 4px; 1876 object-fit: cover; 1877 } 1878 1879 .current-image-label { 1880 color: var(--text-tertiary); 1881 - font-size: 0.85rem; 1882 } 1883 1884 .edit-input:focus { ··· 1910 .album-card { 1911 background: var(--bg-tertiary); 1912 border: 1px solid var(--border-subtle); 1913 - border-radius: 8px; 1914 padding: 1rem; 1915 transition: all 0.2s; 1916 display: flex; ··· 1928 .album-cover { 1929 width: 100%; 1930 aspect-ratio: 1; 1931 - border-radius: 6px; 1932 object-fit: cover; 1933 } 1934 1935 .album-cover-placeholder { 1936 width: 100%; 1937 aspect-ratio: 1; 1938 - border-radius: 6px; 1939 background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 1940 display: flex; 1941 align-items: center; ··· 1949 } 1950 1951 .album-title { 1952 - font-size: 1rem; 1953 font-weight: 600; 1954 color: var(--text-primary); 1955 margin: 0 0 0.25rem 0; ··· 1959 } 1960 1961 .album-stats { 1962 - font-size: 0.85rem; 1963 color: var(--text-tertiary); 1964 margin: 0; 1965 } ··· 1977 .view-playlists-link { 1978 color: var(--text-secondary); 1979 text-decoration: none; 1980 - font-size: 0.8rem; 1981 padding: 0.35rem 0.6rem; 1982 background: var(--bg-tertiary); 1983 - border-radius: 5px; 1984 border: 1px solid var(--border-default); 1985 transition: all 0.15s; 1986 white-space: nowrap; ··· 2001 .playlist-card { 2002 background: var(--bg-tertiary); 2003 border: 1px solid var(--border-subtle); 2004 - border-radius: 8px; 2005 padding: 1rem; 2006 transition: all 0.2s; 2007 display: flex; ··· 2019 .playlist-cover { 2020 width: 100%; 2021 aspect-ratio: 1; 2022 - border-radius: 6px; 2023 object-fit: cover; 2024 } 2025 2026 .playlist-cover-placeholder { 2027 width: 100%; 2028 aspect-ratio: 1; 2029 - border-radius: 6px; 2030 background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 2031 display: flex; 2032 align-items: center; ··· 2040 } 2041 2042 .playlist-title { 2043 - font-size: 1rem; 2044 font-weight: 600; 2045 color: var(--text-primary); 2046 margin: 0 0 0.25rem 0; ··· 2050 } 2051 2052 .playlist-stats { 2053 - font-size: 0.85rem; 2054 color: var(--text-tertiary); 2055 margin: 0; 2056 } ··· 2069 padding: 1rem 1.25rem; 2070 background: var(--bg-tertiary); 2071 border: 1px solid var(--border-subtle); 2072 - border-radius: 8px; 2073 display: flex; 2074 justify-content: space-between; 2075 align-items: center; ··· 2087 } 2088 2089 .control-info h3 { 2090 - font-size: 0.9rem; 2091 font-weight: 600; 2092 margin: 0 0 0.15rem 0; 2093 color: var(--text-primary); 2094 } 2095 2096 .control-description { 2097 - font-size: 0.75rem; 2098 color: var(--text-tertiary); 2099 margin: 0; 2100 line-height: 1.4; ··· 2105 background: var(--accent); 2106 color: var(--text-primary); 2107 border: none; 2108 - border-radius: 6px; 2109 - font-size: 0.9rem; 2110 font-weight: 600; 2111 cursor: pointer; 2112 transition: all 0.2s; ··· 2150 background: transparent; 2151 color: var(--error); 2152 border: 1px solid var(--error); 2153 - border-radius: 6px; 2154 font-family: inherit; 2155 - font-size: 0.9rem; 2156 font-weight: 600; 2157 cursor: pointer; 2158 transition: all 0.2s; ··· 2168 padding: 1rem; 2169 background: var(--bg-primary); 2170 border: 1px solid var(--border-default); 2171 - border-radius: 8px; 2172 } 2173 2174 .delete-warning { 2175 margin: 0 0 1rem; 2176 color: var(--error); 2177 - font-size: 0.9rem; 2178 line-height: 1.5; 2179 } 2180 ··· 2182 margin-bottom: 1rem; 2183 padding: 0.75rem; 2184 background: var(--bg-tertiary); 2185 - border-radius: 6px; 2186 } 2187 2188 .atproto-option { 2189 display: flex; 2190 align-items: center; 2191 gap: 0.5rem; 2192 - font-size: 0.9rem; 2193 color: var(--text-primary); 2194 cursor: pointer; 2195 } ··· 2202 2203 .atproto-note { 2204 margin: 0.5rem 0 0; 2205 - font-size: 0.8rem; 2206 color: var(--text-tertiary); 2207 } 2208 ··· 2219 margin: 0.5rem 0 0; 2220 padding: 0.5rem; 2221 background: color-mix(in srgb, var(--warning) 10%, transparent); 2222 - border-radius: 4px; 2223 - font-size: 0.8rem; 2224 color: var(--warning); 2225 } 2226 2227 .confirm-prompt { 2228 margin: 0 0 0.5rem; 2229 - font-size: 0.9rem; 2230 color: var(--text-secondary); 2231 } 2232 ··· 2235 padding: 0.6rem 0.75rem; 2236 background: var(--bg-tertiary); 2237 border: 1px solid var(--border-default); 2238 - border-radius: 6px; 2239 color: var(--text-primary); 2240 - font-size: 0.9rem; 2241 font-family: inherit; 2242 margin-bottom: 1rem; 2243 } ··· 2257 padding: 0.6rem; 2258 background: transparent; 2259 border: 1px solid var(--border-default); 2260 - border-radius: 6px; 2261 color: var(--text-secondary); 2262 font-family: inherit; 2263 - font-size: 0.9rem; 2264 cursor: pointer; 2265 transition: all 0.15s; 2266 } ··· 2279 padding: 0.6rem; 2280 background: var(--error); 2281 border: none; 2282 - border-radius: 6px; 2283 color: white; 2284 font-family: inherit; 2285 - font-size: 0.9rem; 2286 font-weight: 600; 2287 cursor: pointer; 2288 transition: all 0.15s; ··· 2308 } 2309 2310 .portal-header h2 { 2311 - font-size: 1.25rem; 2312 } 2313 2314 .profile-section h2, ··· 2316 .albums-section h2, 2317 .playlists-section h2, 2318 .data-section h2 { 2319 - font-size: 1.1rem; 2320 } 2321 2322 .section-header { ··· 2324 } 2325 2326 .view-profile-link { 2327 - font-size: 0.75rem; 2328 padding: 0.3rem 0.5rem; 2329 } 2330 ··· 2337 } 2338 2339 label { 2340 - font-size: 0.8rem; 2341 margin-bottom: 0.3rem; 2342 } 2343 ··· 2345 input[type='url'], 2346 textarea { 2347 padding: 0.5rem 0.6rem; 2348 - font-size: 0.9rem; 2349 } 2350 2351 textarea { ··· 2353 } 2354 2355 .hint { 2356 - font-size: 0.7rem; 2357 } 2358 2359 .avatar-preview img { ··· 2363 2364 button[type="submit"] { 2365 padding: 0.6rem; 2366 - font-size: 0.9rem; 2367 } 2368 2369 /* upload card mobile */ ··· 2383 } 2384 2385 .upload-card-title { 2386 - font-size: 0.9rem; 2387 } 2388 2389 .upload-card-subtitle { 2390 - font-size: 0.75rem; 2391 } 2392 2393 /* tracks mobile */ ··· 2421 } 2422 2423 .track-title { 2424 - font-size: 0.9rem; 2425 } 2426 2427 .track-meta { 2428 - font-size: 0.8rem; 2429 } 2430 2431 .track-date { 2432 - font-size: 0.75rem; 2433 } 2434 2435 .track-actions { ··· 2457 } 2458 2459 .edit-label { 2460 - font-size: 0.8rem; 2461 } 2462 2463 .edit-input { 2464 padding: 0.45rem 0.5rem; 2465 - font-size: 0.85rem; 2466 } 2467 2468 .edit-actions { ··· 2476 } 2477 2478 .control-info h3 { 2479 - font-size: 0.85rem; 2480 } 2481 2482 .control-description { 2483 - font-size: 0.7rem; 2484 } 2485 2486 .export-btn { 2487 padding: 0.5rem 0.85rem; 2488 - font-size: 0.8rem; 2489 } 2490 2491 /* albums mobile */ ··· 2500 } 2501 2502 .album-title { 2503 - font-size: 0.85rem; 2504 } 2505 2506 /* playlists mobile */ ··· 2515 } 2516 2517 .playlist-title { 2518 - font-size: 0.85rem; 2519 } 2520 2521 .playlist-stats { 2522 - font-size: 0.75rem; 2523 } 2524 2525 .view-playlists-link { 2526 - font-size: 0.75rem; 2527 padding: 0.3rem 0.5rem; 2528 } 2529 }
··· 106 } 107 108 try { 109 + await Promise.all([ 110 + loadMyTracks(), 111 + loadArtistProfile(), 112 + loadMyAlbums(), 113 + loadMyPlaylists() 114 + ]); 115 } catch (_e) { 116 console.error('error loading portal data:', _e); 117 error = 'failed to load portal data'; ··· 1144 .view-profile-link { 1145 color: var(--text-secondary); 1146 text-decoration: none; 1147 + font-size: var(--text-sm); 1148 padding: 0.35rem 0.6rem; 1149 background: var(--bg-tertiary); 1150 + border-radius: var(--radius-sm); 1151 border: 1px solid var(--border-default); 1152 transition: all 0.15s; 1153 white-space: nowrap; ··· 1162 .settings-link { 1163 color: var(--text-secondary); 1164 text-decoration: none; 1165 + font-size: var(--text-sm); 1166 padding: 0.35rem 0.6rem; 1167 background: var(--bg-tertiary); 1168 + border-radius: var(--radius-sm); 1169 border: 1px solid var(--border-default); 1170 transition: all 0.15s; 1171 white-space: nowrap; ··· 1185 padding: 1rem 1.25rem; 1186 background: var(--bg-tertiary); 1187 border: 1px solid var(--border-default); 1188 + border-radius: var(--radius-md); 1189 text-decoration: none; 1190 color: var(--text-primary); 1191 transition: all 0.15s; ··· 1208 width: 44px; 1209 height: 44px; 1210 background: color-mix(in srgb, var(--accent) 15%, transparent); 1211 + border-radius: var(--radius-md); 1212 color: var(--accent); 1213 flex-shrink: 0; 1214 } ··· 1221 .upload-card-title { 1222 display: block; 1223 font-weight: 600; 1224 + font-size: var(--text-base); 1225 color: var(--text-primary); 1226 } 1227 1228 .upload-card-subtitle { 1229 display: block; 1230 + font-size: var(--text-sm); 1231 color: var(--text-tertiary); 1232 } 1233 ··· 1245 form { 1246 background: var(--bg-tertiary); 1247 padding: 1.25rem; 1248 + border-radius: var(--radius-md); 1249 border: 1px solid var(--border-subtle); 1250 } 1251 ··· 1261 display: block; 1262 color: var(--text-secondary); 1263 margin-bottom: 0.4rem; 1264 + font-size: var(--text-sm); 1265 } 1266 1267 + input[type='text'], 1268 + input[type='url'], 1269 + textarea { 1270 width: 100%; 1271 padding: 0.6rem 0.75rem; 1272 background: var(--bg-primary); 1273 border: 1px solid var(--border-default); 1274 + border-radius: var(--radius-sm); 1275 color: var(--text-primary); 1276 + font-size: var(--text-base); 1277 font-family: inherit; 1278 transition: all 0.15s; 1279 } 1280 1281 + input[type='text']:focus, 1282 + input[type='url']:focus, 1283 + textarea:focus { 1284 outline: none; 1285 border-color: var(--accent); 1286 } 1287 1288 + input[type='text']:disabled, 1289 + input[type='url']:disabled, 1290 + textarea:disabled { 1291 opacity: 0.5; 1292 cursor: not-allowed; 1293 } 1294 1295 textarea { 1296 resize: vertical; 1297 min-height: 80px; 1298 } 1299 1300 .hint { 1301 margin-top: 0.35rem; 1302 + font-size: var(--text-xs); 1303 color: var(--text-muted); 1304 } 1305 ··· 1317 display: block; 1318 color: var(--text-secondary); 1319 margin-bottom: 0.6rem; 1320 + font-size: var(--text-sm); 1321 } 1322 1323 .support-options { ··· 1334 padding: 0.6rem 0.75rem; 1335 background: var(--bg-primary); 1336 border: 1px solid var(--border-default); 1337 + border-radius: var(--radius-base); 1338 cursor: pointer; 1339 transition: all 0.15s; 1340 margin-bottom: 0; ··· 1357 } 1358 1359 .support-option span { 1360 + font-size: var(--text-base); 1361 color: var(--text-primary); 1362 } 1363 1364 .support-status { 1365 margin-left: auto; 1366 + font-size: var(--text-xs); 1367 color: var(--text-tertiary); 1368 } 1369 1370 .support-setup-link, 1371 .support-status-link { 1372 margin-left: auto; 1373 + font-size: var(--text-xs); 1374 text-decoration: none; 1375 } 1376 ··· 1401 padding: 0.6rem 0.75rem; 1402 background: var(--bg-primary); 1403 border: 1px solid var(--border-default); 1404 + border-radius: var(--radius-sm); 1405 color: var(--text-primary); 1406 + font-size: var(--text-base); 1407 font-family: inherit; 1408 transition: all 0.15s; 1409 margin-bottom: 0.5rem; ··· 1429 .avatar-preview img { 1430 width: 64px; 1431 height: 64px; 1432 + border-radius: var(--radius-full); 1433 object-fit: cover; 1434 border: 2px solid var(--border-default); 1435 } ··· 1439 padding: 0.75rem; 1440 background: var(--bg-primary); 1441 border: 1px solid var(--border-default); 1442 + border-radius: var(--radius-sm); 1443 color: var(--text-primary); 1444 + font-size: var(--text-base); 1445 font-family: inherit; 1446 cursor: pointer; 1447 } ··· 1453 1454 .file-info { 1455 margin-top: 0.5rem; 1456 + font-size: var(--text-sm); 1457 color: var(--text-muted); 1458 } 1459 ··· 1463 background: var(--accent); 1464 color: var(--text-primary); 1465 border: none; 1466 + border-radius: var(--radius-sm); 1467 + font-size: var(--text-lg); 1468 font-weight: 600; 1469 font-family: inherit; 1470 cursor: pointer; ··· 1501 padding: 2rem; 1502 text-align: center; 1503 background: var(--bg-tertiary); 1504 + border-radius: var(--radius-md); 1505 border: 1px solid var(--border-subtle); 1506 } 1507 ··· 1518 gap: 1rem; 1519 background: var(--bg-tertiary); 1520 border: 1px solid var(--border-subtle); 1521 + border-radius: var(--radius-base); 1522 padding: 1rem; 1523 transition: all 0.2s; 1524 } ··· 1553 .track-artwork { 1554 width: 48px; 1555 height: 48px; 1556 + border-radius: var(--radius-sm); 1557 overflow: hidden; 1558 background: var(--bg-primary); 1559 border: 1px solid var(--border-subtle); ··· 1575 } 1576 1577 .track-view-link { 1578 + font-size: var(--text-xs); 1579 color: var(--text-muted); 1580 text-decoration: none; 1581 transition: color 0.15s; ··· 1622 } 1623 1624 .edit-label { 1625 + font-size: var(--text-sm); 1626 color: var(--text-secondary); 1627 } 1628 ··· 1631 align-items: center; 1632 gap: 0.5rem; 1633 cursor: pointer; 1634 + font-size: var(--text-base); 1635 color: var(--text-primary); 1636 } 1637 ··· 1642 } 1643 1644 .field-hint { 1645 + font-size: var(--text-sm); 1646 color: var(--text-tertiary); 1647 margin-top: 0.25rem; 1648 } ··· 1658 1659 .track-title { 1660 font-weight: 600; 1661 + font-size: var(--text-lg); 1662 margin-bottom: 0.25rem; 1663 color: var(--text-primary); 1664 display: flex; ··· 1694 } 1695 1696 .track-meta { 1697 + font-size: var(--text-base); 1698 color: var(--text-secondary); 1699 margin-bottom: 0.25rem; 1700 display: flex; ··· 1762 padding: 0.1rem 0.4rem; 1763 background: color-mix(in srgb, var(--accent) 15%, transparent); 1764 color: var(--accent-hover); 1765 + border-radius: var(--radius-sm); 1766 + font-size: var(--text-sm); 1767 font-weight: 500; 1768 text-decoration: none; 1769 transition: all 0.15s; ··· 1775 } 1776 1777 .track-date { 1778 + font-size: var(--text-sm); 1779 color: var(--text-muted); 1780 } 1781 ··· 1796 padding: 0; 1797 background: transparent; 1798 border: 1px solid var(--border-default); 1799 + border-radius: var(--radius-base); 1800 color: var(--text-tertiary); 1801 cursor: pointer; 1802 transition: all 0.15s; ··· 1841 padding: 0.5rem; 1842 background: var(--bg-primary); 1843 border: 1px solid var(--border-default); 1844 + border-radius: var(--radius-sm); 1845 color: var(--text-primary); 1846 + font-size: var(--text-base); 1847 font-family: inherit; 1848 } 1849 ··· 1854 padding: 0.5rem; 1855 background: var(--bg-primary); 1856 border: 1px solid var(--border-default); 1857 + border-radius: var(--radius-sm); 1858 margin-bottom: 0.5rem; 1859 } 1860 1861 .current-image-preview img { 1862 width: 48px; 1863 height: 48px; 1864 + border-radius: var(--radius-sm); 1865 object-fit: cover; 1866 } 1867 1868 .current-image-label { 1869 color: var(--text-tertiary); 1870 + font-size: var(--text-sm); 1871 } 1872 1873 .edit-input:focus { ··· 1899 .album-card { 1900 background: var(--bg-tertiary); 1901 border: 1px solid var(--border-subtle); 1902 + border-radius: var(--radius-md); 1903 padding: 1rem; 1904 transition: all 0.2s; 1905 display: flex; ··· 1917 .album-cover { 1918 width: 100%; 1919 aspect-ratio: 1; 1920 + border-radius: var(--radius-base); 1921 object-fit: cover; 1922 } 1923 1924 .album-cover-placeholder { 1925 width: 100%; 1926 aspect-ratio: 1; 1927 + border-radius: var(--radius-base); 1928 background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 1929 display: flex; 1930 align-items: center; ··· 1938 } 1939 1940 .album-title { 1941 + font-size: var(--text-lg); 1942 font-weight: 600; 1943 color: var(--text-primary); 1944 margin: 0 0 0.25rem 0; ··· 1948 } 1949 1950 .album-stats { 1951 + font-size: var(--text-sm); 1952 color: var(--text-tertiary); 1953 margin: 0; 1954 } ··· 1966 .view-playlists-link { 1967 color: var(--text-secondary); 1968 text-decoration: none; 1969 + font-size: var(--text-sm); 1970 padding: 0.35rem 0.6rem; 1971 background: var(--bg-tertiary); 1972 + border-radius: var(--radius-sm); 1973 border: 1px solid var(--border-default); 1974 transition: all 0.15s; 1975 white-space: nowrap; ··· 1990 .playlist-card { 1991 background: var(--bg-tertiary); 1992 border: 1px solid var(--border-subtle); 1993 + border-radius: var(--radius-md); 1994 padding: 1rem; 1995 transition: all 0.2s; 1996 display: flex; ··· 2008 .playlist-cover { 2009 width: 100%; 2010 aspect-ratio: 1; 2011 + border-radius: var(--radius-base); 2012 object-fit: cover; 2013 } 2014 2015 .playlist-cover-placeholder { 2016 width: 100%; 2017 aspect-ratio: 1; 2018 + border-radius: var(--radius-base); 2019 background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 2020 display: flex; 2021 align-items: center; ··· 2029 } 2030 2031 .playlist-title { 2032 + font-size: var(--text-lg); 2033 font-weight: 600; 2034 color: var(--text-primary); 2035 margin: 0 0 0.25rem 0; ··· 2039 } 2040 2041 .playlist-stats { 2042 + font-size: var(--text-sm); 2043 color: var(--text-tertiary); 2044 margin: 0; 2045 } ··· 2058 padding: 1rem 1.25rem; 2059 background: var(--bg-tertiary); 2060 border: 1px solid var(--border-subtle); 2061 + border-radius: var(--radius-md); 2062 display: flex; 2063 justify-content: space-between; 2064 align-items: center; ··· 2076 } 2077 2078 .control-info h3 { 2079 + font-size: var(--text-base); 2080 font-weight: 600; 2081 margin: 0 0 0.15rem 0; 2082 color: var(--text-primary); 2083 } 2084 2085 .control-description { 2086 + font-size: var(--text-xs); 2087 color: var(--text-tertiary); 2088 margin: 0; 2089 line-height: 1.4; ··· 2094 background: var(--accent); 2095 color: var(--text-primary); 2096 border: none; 2097 + border-radius: var(--radius-base); 2098 + font-size: var(--text-base); 2099 font-weight: 600; 2100 cursor: pointer; 2101 transition: all 0.2s; ··· 2139 background: transparent; 2140 color: var(--error); 2141 border: 1px solid var(--error); 2142 + border-radius: var(--radius-base); 2143 font-family: inherit; 2144 + font-size: var(--text-base); 2145 font-weight: 600; 2146 cursor: pointer; 2147 transition: all 0.2s; ··· 2157 padding: 1rem; 2158 background: var(--bg-primary); 2159 border: 1px solid var(--border-default); 2160 + border-radius: var(--radius-md); 2161 } 2162 2163 .delete-warning { 2164 margin: 0 0 1rem; 2165 color: var(--error); 2166 + font-size: var(--text-base); 2167 line-height: 1.5; 2168 } 2169 ··· 2171 margin-bottom: 1rem; 2172 padding: 0.75rem; 2173 background: var(--bg-tertiary); 2174 + border-radius: var(--radius-base); 2175 } 2176 2177 .atproto-option { 2178 display: flex; 2179 align-items: center; 2180 gap: 0.5rem; 2181 + font-size: var(--text-base); 2182 color: var(--text-primary); 2183 cursor: pointer; 2184 } ··· 2191 2192 .atproto-note { 2193 margin: 0.5rem 0 0; 2194 + font-size: var(--text-sm); 2195 color: var(--text-tertiary); 2196 } 2197 ··· 2208 margin: 0.5rem 0 0; 2209 padding: 0.5rem; 2210 background: color-mix(in srgb, var(--warning) 10%, transparent); 2211 + border-radius: var(--radius-sm); 2212 + font-size: var(--text-sm); 2213 color: var(--warning); 2214 } 2215 2216 .confirm-prompt { 2217 margin: 0 0 0.5rem; 2218 + font-size: var(--text-base); 2219 color: var(--text-secondary); 2220 } 2221 ··· 2224 padding: 0.6rem 0.75rem; 2225 background: var(--bg-tertiary); 2226 border: 1px solid var(--border-default); 2227 + border-radius: var(--radius-base); 2228 color: var(--text-primary); 2229 + font-size: var(--text-base); 2230 font-family: inherit; 2231 margin-bottom: 1rem; 2232 } ··· 2246 padding: 0.6rem; 2247 background: transparent; 2248 border: 1px solid var(--border-default); 2249 + border-radius: var(--radius-base); 2250 color: var(--text-secondary); 2251 font-family: inherit; 2252 + font-size: var(--text-base); 2253 cursor: pointer; 2254 transition: all 0.15s; 2255 } ··· 2268 padding: 0.6rem; 2269 background: var(--error); 2270 border: none; 2271 + border-radius: var(--radius-base); 2272 color: white; 2273 font-family: inherit; 2274 + font-size: var(--text-base); 2275 font-weight: 600; 2276 cursor: pointer; 2277 transition: all 0.15s; ··· 2297 } 2298 2299 .portal-header h2 { 2300 + font-size: var(--text-2xl); 2301 } 2302 2303 .profile-section h2, ··· 2305 .albums-section h2, 2306 .playlists-section h2, 2307 .data-section h2 { 2308 + font-size: var(--text-xl); 2309 } 2310 2311 .section-header { ··· 2313 } 2314 2315 .view-profile-link { 2316 + font-size: var(--text-xs); 2317 padding: 0.3rem 0.5rem; 2318 } 2319 ··· 2326 } 2327 2328 label { 2329 + font-size: var(--text-sm); 2330 margin-bottom: 0.3rem; 2331 } 2332 ··· 2334 input[type='url'], 2335 textarea { 2336 padding: 0.5rem 0.6rem; 2337 + font-size: var(--text-base); 2338 } 2339 2340 textarea { ··· 2342 } 2343 2344 .hint { 2345 + font-size: var(--text-xs); 2346 } 2347 2348 .avatar-preview img { ··· 2352 2353 button[type="submit"] { 2354 padding: 0.6rem; 2355 + font-size: var(--text-base); 2356 } 2357 2358 /* upload card mobile */ ··· 2372 } 2373 2374 .upload-card-title { 2375 + font-size: var(--text-base); 2376 } 2377 2378 .upload-card-subtitle { 2379 + font-size: var(--text-xs); 2380 } 2381 2382 /* tracks mobile */ ··· 2410 } 2411 2412 .track-title { 2413 + font-size: var(--text-base); 2414 } 2415 2416 .track-meta { 2417 + font-size: var(--text-sm); 2418 } 2419 2420 .track-date { 2421 + font-size: var(--text-xs); 2422 } 2423 2424 .track-actions { ··· 2446 } 2447 2448 .edit-label { 2449 + font-size: var(--text-sm); 2450 } 2451 2452 .edit-input { 2453 padding: 0.45rem 0.5rem; 2454 + font-size: var(--text-sm); 2455 } 2456 2457 .edit-actions { ··· 2465 } 2466 2467 .control-info h3 { 2468 + font-size: var(--text-sm); 2469 } 2470 2471 .control-description { 2472 + font-size: var(--text-xs); 2473 } 2474 2475 .export-btn { 2476 padding: 0.5rem 0.85rem; 2477 + font-size: var(--text-sm); 2478 } 2479 2480 /* albums mobile */ ··· 2489 } 2490 2491 .album-title { 2492 + font-size: var(--text-sm); 2493 } 2494 2495 /* playlists mobile */ ··· 2504 } 2505 2506 .playlist-title { 2507 + font-size: var(--text-sm); 2508 } 2509 2510 .playlist-stats { 2511 + font-size: var(--text-xs); 2512 } 2513 2514 .view-playlists-link { 2515 + font-size: var(--text-xs); 2516 padding: 0.3rem 0.5rem; 2517 } 2518 }
+8 -8
frontend/src/routes/profile/setup/+page.svelte
··· 222 223 .error { 224 padding: 1rem; 225 - border-radius: 4px; 226 margin-bottom: 1.5rem; 227 background: color-mix(in srgb, var(--error) 10%, transparent); 228 border: 1px solid color-mix(in srgb, var(--error) 30%, transparent); ··· 232 form { 233 background: var(--bg-tertiary); 234 padding: 2rem; 235 - border-radius: 8px; 236 border: 1px solid var(--border-subtle); 237 } 238 ··· 248 display: block; 249 color: var(--text-secondary); 250 margin-bottom: 0.5rem; 251 - font-size: 0.9rem; 252 font-weight: 500; 253 } 254 ··· 259 padding: 0.75rem; 260 background: var(--bg-primary); 261 border: 1px solid var(--border-default); 262 - border-radius: 4px; 263 color: var(--text-primary); 264 - font-size: 1rem; 265 font-family: inherit; 266 transition: all 0.2s; 267 } ··· 287 288 .hint { 289 margin-top: 0.5rem; 290 - font-size: 0.85rem; 291 color: var(--text-muted); 292 } 293 ··· 297 background: var(--accent); 298 color: white; 299 border: none; 300 - border-radius: 4px; 301 - font-size: 1rem; 302 font-weight: 600; 303 cursor: pointer; 304 transition: all 0.2s;
··· 222 223 .error { 224 padding: 1rem; 225 + border-radius: var(--radius-sm); 226 margin-bottom: 1.5rem; 227 background: color-mix(in srgb, var(--error) 10%, transparent); 228 border: 1px solid color-mix(in srgb, var(--error) 30%, transparent); ··· 232 form { 233 background: var(--bg-tertiary); 234 padding: 2rem; 235 + border-radius: var(--radius-md); 236 border: 1px solid var(--border-subtle); 237 } 238 ··· 248 display: block; 249 color: var(--text-secondary); 250 margin-bottom: 0.5rem; 251 + font-size: var(--text-base); 252 font-weight: 500; 253 } 254 ··· 259 padding: 0.75rem; 260 background: var(--bg-primary); 261 border: 1px solid var(--border-default); 262 + border-radius: var(--radius-sm); 263 color: var(--text-primary); 264 + font-size: var(--text-lg); 265 font-family: inherit; 266 transition: all 0.2s; 267 } ··· 287 288 .hint { 289 margin-top: 0.5rem; 290 + font-size: var(--text-sm); 291 color: var(--text-muted); 292 } 293 ··· 297 background: var(--accent); 298 color: white; 299 border: none; 300 + border-radius: var(--radius-sm); 301 + font-size: var(--text-lg); 302 font-weight: 600; 303 cursor: pointer; 304 transition: all 0.2s;
+46 -46
frontend/src/routes/settings/+page.svelte
··· 774 .token-overlay-content { 775 background: var(--bg-secondary); 776 border: 1px solid var(--border-default); 777 - border-radius: 16px; 778 padding: 2rem; 779 max-width: 500px; 780 width: 100%; ··· 788 789 .token-overlay-content h2 { 790 margin: 0 0 0.75rem; 791 - font-size: 1.5rem; 792 color: var(--text-primary); 793 } 794 795 .token-overlay-warning { 796 color: var(--warning); 797 - font-size: 0.9rem; 798 margin: 0 0 1.5rem; 799 line-height: 1.5; 800 } ··· 804 gap: 0.5rem; 805 background: var(--bg-primary); 806 border: 1px solid var(--border-default); 807 - border-radius: 8px; 808 padding: 1rem; 809 margin-bottom: 1rem; 810 } 811 812 .token-overlay-display code { 813 flex: 1; 814 - font-size: 0.85rem; 815 word-break: break-all; 816 color: var(--accent); 817 text-align: left; ··· 822 padding: 0.5rem 1rem; 823 background: var(--accent); 824 border: none; 825 - border-radius: 6px; 826 color: var(--text-primary); 827 font-family: inherit; 828 - font-size: 0.85rem; 829 font-weight: 600; 830 cursor: pointer; 831 white-space: nowrap; ··· 837 } 838 839 .token-overlay-hint { 840 - font-size: 0.8rem; 841 color: var(--text-tertiary); 842 margin: 0 0 1.5rem; 843 } ··· 855 padding: 0.75rem 2rem; 856 background: var(--bg-tertiary); 857 border: 1px solid var(--border-default); 858 - border-radius: 8px; 859 color: var(--text-secondary); 860 font-family: inherit; 861 - font-size: 0.9rem; 862 cursor: pointer; 863 transition: all 0.15s; 864 } ··· 901 .portal-link { 902 color: var(--text-secondary); 903 text-decoration: none; 904 - font-size: 0.85rem; 905 padding: 0.4rem 0.75rem; 906 background: var(--bg-tertiary); 907 - border-radius: 6px; 908 border: 1px solid var(--border-default); 909 transition: all 0.15s; 910 } ··· 919 } 920 921 .settings-section h2 { 922 - font-size: 0.8rem; 923 text-transform: uppercase; 924 letter-spacing: 0.08em; 925 color: var(--text-tertiary); ··· 930 .settings-card { 931 background: var(--bg-tertiary); 932 border: 1px solid var(--border-subtle); 933 - border-radius: 10px; 934 padding: 1rem 1.25rem; 935 } 936 ··· 957 958 .setting-info h3 { 959 margin: 0 0 0.25rem; 960 - font-size: 0.95rem; 961 font-weight: 600; 962 color: var(--text-primary); 963 } 964 965 .setting-info p { 966 margin: 0; 967 - font-size: 0.8rem; 968 color: var(--text-tertiary); 969 line-height: 1.4; 970 } ··· 993 padding: 0.6rem 0.75rem; 994 background: var(--bg-primary); 995 border: 1px solid var(--border-default); 996 - border-radius: 8px; 997 color: var(--text-secondary); 998 cursor: pointer; 999 transition: all 0.15s; ··· 1034 width: 40px; 1035 height: 40px; 1036 border: 1px solid var(--border-default); 1037 - border-radius: 8px; 1038 cursor: pointer; 1039 background: transparent; 1040 } ··· 1044 } 1045 1046 .color-input::-webkit-color-swatch { 1047 - border-radius: 4px; 1048 border: none; 1049 } 1050 ··· 1056 .preset-btn { 1057 width: 32px; 1058 height: 32px; 1059 - border-radius: 6px; 1060 border: 2px solid transparent; 1061 cursor: pointer; 1062 transition: all 0.15s; ··· 1085 padding: 0.5rem 0.75rem; 1086 background: var(--bg-primary); 1087 border: 1px solid var(--border-default); 1088 - border-radius: 6px; 1089 color: var(--text-primary); 1090 - font-size: 0.85rem; 1091 font-family: inherit; 1092 } 1093 ··· 1104 display: flex; 1105 align-items: center; 1106 gap: 0.4rem; 1107 - font-size: 0.8rem; 1108 color: var(--text-secondary); 1109 cursor: pointer; 1110 } ··· 1132 width: 48px; 1133 height: 28px; 1134 background: var(--border-default); 1135 - border-radius: 999px; 1136 position: relative; 1137 cursor: pointer; 1138 transition: background 0.2s; ··· 1145 left: 4px; 1146 width: 20px; 1147 height: 20px; 1148 - border-radius: 50%; 1149 background: var(--text-secondary); 1150 transition: transform 0.2s, background 0.2s; 1151 } ··· 1167 padding: 0.75rem; 1168 background: color-mix(in srgb, var(--warning) 10%, transparent); 1169 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 1170 - border-radius: 6px; 1171 margin-top: 0.75rem; 1172 - font-size: 0.8rem; 1173 color: var(--warning); 1174 } 1175 ··· 1191 /* developer tokens */ 1192 .loading-tokens { 1193 color: var(--text-tertiary); 1194 - font-size: 0.85rem; 1195 } 1196 1197 .existing-tokens { ··· 1199 } 1200 1201 .tokens-header { 1202 - font-size: 0.75rem; 1203 text-transform: uppercase; 1204 letter-spacing: 0.05em; 1205 color: var(--text-tertiary); ··· 1220 padding: 0.75rem; 1221 background: var(--bg-primary); 1222 border: 1px solid var(--border-default); 1223 - border-radius: 6px; 1224 } 1225 1226 .token-info { ··· 1233 .token-name { 1234 font-weight: 500; 1235 color: var(--text-primary); 1236 - font-size: 0.9rem; 1237 } 1238 1239 .token-meta { 1240 - font-size: 0.75rem; 1241 color: var(--text-tertiary); 1242 } 1243 ··· 1245 padding: 0.4rem 0.75rem; 1246 background: transparent; 1247 border: 1px solid var(--border-emphasis); 1248 - border-radius: 4px; 1249 color: var(--text-secondary); 1250 font-family: inherit; 1251 - font-size: 0.8rem; 1252 cursor: pointer; 1253 transition: all 0.15s; 1254 white-space: nowrap; ··· 1272 padding: 0.75rem; 1273 background: var(--bg-primary); 1274 border: 1px solid var(--border-default); 1275 - border-radius: 6px; 1276 } 1277 1278 .token-value { 1279 flex: 1; 1280 - font-size: 0.8rem; 1281 word-break: break-all; 1282 color: var(--accent); 1283 } ··· 1287 padding: 0.4rem 0.6rem; 1288 background: var(--bg-tertiary); 1289 border: 1px solid var(--border-default); 1290 - border-radius: 4px; 1291 color: var(--text-secondary); 1292 font-family: inherit; 1293 - font-size: 0.8rem; 1294 cursor: pointer; 1295 transition: all 0.15s; 1296 } ··· 1303 1304 .token-warning { 1305 margin-top: 0.5rem; 1306 - font-size: 0.8rem; 1307 color: var(--warning); 1308 } 1309 ··· 1320 padding: 0.6rem 0.75rem; 1321 background: var(--bg-primary); 1322 border: 1px solid var(--border-default); 1323 - border-radius: 6px; 1324 color: var(--text-primary); 1325 - font-size: 0.9rem; 1326 font-family: inherit; 1327 } 1328 ··· 1335 display: flex; 1336 align-items: center; 1337 gap: 0.5rem; 1338 - font-size: 0.85rem; 1339 color: var(--text-secondary); 1340 } 1341 ··· 1343 padding: 0.5rem 0.75rem; 1344 background: var(--bg-primary); 1345 border: 1px solid var(--border-default); 1346 - border-radius: 6px; 1347 color: var(--text-primary); 1348 - font-size: 0.85rem; 1349 font-family: inherit; 1350 cursor: pointer; 1351 } ··· 1359 padding: 0.6rem 1rem; 1360 background: var(--accent); 1361 border: none; 1362 - border-radius: 6px; 1363 color: var(--text-primary); 1364 font-family: inherit; 1365 - font-size: 0.9rem; 1366 font-weight: 600; 1367 cursor: pointer; 1368 transition: all 0.15s;
··· 774 .token-overlay-content { 775 background: var(--bg-secondary); 776 border: 1px solid var(--border-default); 777 + border-radius: var(--radius-xl); 778 padding: 2rem; 779 max-width: 500px; 780 width: 100%; ··· 788 789 .token-overlay-content h2 { 790 margin: 0 0 0.75rem; 791 + font-size: var(--text-3xl); 792 color: var(--text-primary); 793 } 794 795 .token-overlay-warning { 796 color: var(--warning); 797 + font-size: var(--text-base); 798 margin: 0 0 1.5rem; 799 line-height: 1.5; 800 } ··· 804 gap: 0.5rem; 805 background: var(--bg-primary); 806 border: 1px solid var(--border-default); 807 + border-radius: var(--radius-md); 808 padding: 1rem; 809 margin-bottom: 1rem; 810 } 811 812 .token-overlay-display code { 813 flex: 1; 814 + font-size: var(--text-sm); 815 word-break: break-all; 816 color: var(--accent); 817 text-align: left; ··· 822 padding: 0.5rem 1rem; 823 background: var(--accent); 824 border: none; 825 + border-radius: var(--radius-base); 826 color: var(--text-primary); 827 font-family: inherit; 828 + font-size: var(--text-sm); 829 font-weight: 600; 830 cursor: pointer; 831 white-space: nowrap; ··· 837 } 838 839 .token-overlay-hint { 840 + font-size: var(--text-sm); 841 color: var(--text-tertiary); 842 margin: 0 0 1.5rem; 843 } ··· 855 padding: 0.75rem 2rem; 856 background: var(--bg-tertiary); 857 border: 1px solid var(--border-default); 858 + border-radius: var(--radius-md); 859 color: var(--text-secondary); 860 font-family: inherit; 861 + font-size: var(--text-base); 862 cursor: pointer; 863 transition: all 0.15s; 864 } ··· 901 .portal-link { 902 color: var(--text-secondary); 903 text-decoration: none; 904 + font-size: var(--text-sm); 905 padding: 0.4rem 0.75rem; 906 background: var(--bg-tertiary); 907 + border-radius: var(--radius-base); 908 border: 1px solid var(--border-default); 909 transition: all 0.15s; 910 } ··· 919 } 920 921 .settings-section h2 { 922 + font-size: var(--text-sm); 923 text-transform: uppercase; 924 letter-spacing: 0.08em; 925 color: var(--text-tertiary); ··· 930 .settings-card { 931 background: var(--bg-tertiary); 932 border: 1px solid var(--border-subtle); 933 + border-radius: var(--radius-md); 934 padding: 1rem 1.25rem; 935 } 936 ··· 957 958 .setting-info h3 { 959 margin: 0 0 0.25rem; 960 + font-size: var(--text-base); 961 font-weight: 600; 962 color: var(--text-primary); 963 } 964 965 .setting-info p { 966 margin: 0; 967 + font-size: var(--text-sm); 968 color: var(--text-tertiary); 969 line-height: 1.4; 970 } ··· 993 padding: 0.6rem 0.75rem; 994 background: var(--bg-primary); 995 border: 1px solid var(--border-default); 996 + border-radius: var(--radius-md); 997 color: var(--text-secondary); 998 cursor: pointer; 999 transition: all 0.15s; ··· 1034 width: 40px; 1035 height: 40px; 1036 border: 1px solid var(--border-default); 1037 + border-radius: var(--radius-md); 1038 cursor: pointer; 1039 background: transparent; 1040 } ··· 1044 } 1045 1046 .color-input::-webkit-color-swatch { 1047 + border-radius: var(--radius-sm); 1048 border: none; 1049 } 1050 ··· 1056 .preset-btn { 1057 width: 32px; 1058 height: 32px; 1059 + border-radius: var(--radius-base); 1060 border: 2px solid transparent; 1061 cursor: pointer; 1062 transition: all 0.15s; ··· 1085 padding: 0.5rem 0.75rem; 1086 background: var(--bg-primary); 1087 border: 1px solid var(--border-default); 1088 + border-radius: var(--radius-base); 1089 color: var(--text-primary); 1090 + font-size: var(--text-sm); 1091 font-family: inherit; 1092 } 1093 ··· 1104 display: flex; 1105 align-items: center; 1106 gap: 0.4rem; 1107 + font-size: var(--text-sm); 1108 color: var(--text-secondary); 1109 cursor: pointer; 1110 } ··· 1132 width: 48px; 1133 height: 28px; 1134 background: var(--border-default); 1135 + border-radius: var(--radius-full); 1136 position: relative; 1137 cursor: pointer; 1138 transition: background 0.2s; ··· 1145 left: 4px; 1146 width: 20px; 1147 height: 20px; 1148 + border-radius: var(--radius-full); 1149 background: var(--text-secondary); 1150 transition: transform 0.2s, background 0.2s; 1151 } ··· 1167 padding: 0.75rem; 1168 background: color-mix(in srgb, var(--warning) 10%, transparent); 1169 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 1170 + border-radius: var(--radius-base); 1171 margin-top: 0.75rem; 1172 + font-size: var(--text-sm); 1173 color: var(--warning); 1174 } 1175 ··· 1191 /* developer tokens */ 1192 .loading-tokens { 1193 color: var(--text-tertiary); 1194 + font-size: var(--text-sm); 1195 } 1196 1197 .existing-tokens { ··· 1199 } 1200 1201 .tokens-header { 1202 + font-size: var(--text-xs); 1203 text-transform: uppercase; 1204 letter-spacing: 0.05em; 1205 color: var(--text-tertiary); ··· 1220 padding: 0.75rem; 1221 background: var(--bg-primary); 1222 border: 1px solid var(--border-default); 1223 + border-radius: var(--radius-base); 1224 } 1225 1226 .token-info { ··· 1233 .token-name { 1234 font-weight: 500; 1235 color: var(--text-primary); 1236 + font-size: var(--text-base); 1237 } 1238 1239 .token-meta { 1240 + font-size: var(--text-xs); 1241 color: var(--text-tertiary); 1242 } 1243 ··· 1245 padding: 0.4rem 0.75rem; 1246 background: transparent; 1247 border: 1px solid var(--border-emphasis); 1248 + border-radius: var(--radius-sm); 1249 color: var(--text-secondary); 1250 font-family: inherit; 1251 + font-size: var(--text-sm); 1252 cursor: pointer; 1253 transition: all 0.15s; 1254 white-space: nowrap; ··· 1272 padding: 0.75rem; 1273 background: var(--bg-primary); 1274 border: 1px solid var(--border-default); 1275 + border-radius: var(--radius-base); 1276 } 1277 1278 .token-value { 1279 flex: 1; 1280 + font-size: var(--text-sm); 1281 word-break: break-all; 1282 color: var(--accent); 1283 } ··· 1287 padding: 0.4rem 0.6rem; 1288 background: var(--bg-tertiary); 1289 border: 1px solid var(--border-default); 1290 + border-radius: var(--radius-sm); 1291 color: var(--text-secondary); 1292 font-family: inherit; 1293 + font-size: var(--text-sm); 1294 cursor: pointer; 1295 transition: all 0.15s; 1296 } ··· 1303 1304 .token-warning { 1305 margin-top: 0.5rem; 1306 + font-size: var(--text-sm); 1307 color: var(--warning); 1308 } 1309 ··· 1320 padding: 0.6rem 0.75rem; 1321 background: var(--bg-primary); 1322 border: 1px solid var(--border-default); 1323 + border-radius: var(--radius-base); 1324 color: var(--text-primary); 1325 + font-size: var(--text-base); 1326 font-family: inherit; 1327 } 1328 ··· 1335 display: flex; 1336 align-items: center; 1337 gap: 0.5rem; 1338 + font-size: var(--text-sm); 1339 color: var(--text-secondary); 1340 } 1341 ··· 1343 padding: 0.5rem 0.75rem; 1344 background: var(--bg-primary); 1345 border: 1px solid var(--border-default); 1346 + border-radius: var(--radius-base); 1347 color: var(--text-primary); 1348 + font-size: var(--text-sm); 1349 font-family: inherit; 1350 cursor: pointer; 1351 } ··· 1359 padding: 0.6rem 1rem; 1360 background: var(--accent); 1361 border: none; 1362 + border-radius: var(--radius-base); 1363 color: var(--text-primary); 1364 font-family: inherit; 1365 + font-size: var(--text-base); 1366 font-weight: 600; 1367 cursor: pointer; 1368 transition: all 0.15s;
+10 -10
frontend/src/routes/tag/[name]/+page.svelte
··· 197 } 198 199 .subtitle { 200 - font-size: 0.95rem; 201 color: var(--text-tertiary); 202 margin: 0; 203 text-shadow: var(--text-shadow, none); ··· 211 background: var(--glass-btn-bg, transparent); 212 border: 1px solid var(--glass-btn-border, var(--accent)); 213 color: var(--accent); 214 - border-radius: 6px; 215 - font-size: 0.9rem; 216 font-family: inherit; 217 cursor: pointer; 218 transition: all 0.2s; ··· 240 } 241 242 .empty-state h2 { 243 - font-size: 1.5rem; 244 font-weight: 600; 245 color: var(--text-secondary); 246 margin: 0 0 0.5rem 0; 247 } 248 249 .empty-state p { 250 - font-size: 0.95rem; 251 margin: 0; 252 } 253 ··· 264 text-align: center; 265 padding: 4rem 1rem; 266 color: var(--text-tertiary); 267 - font-size: 0.95rem; 268 } 269 270 .tracks-list { ··· 292 } 293 294 .empty-state h2 { 295 - font-size: 1.25rem; 296 } 297 298 .btn-queue-all { 299 padding: 0.5rem 0.75rem; 300 - font-size: 0.85rem; 301 } 302 303 .btn-queue-all svg { ··· 330 } 331 332 .subtitle { 333 - font-size: 0.85rem; 334 } 335 336 .btn-queue-all { 337 padding: 0.45rem 0.65rem; 338 - font-size: 0.8rem; 339 } 340 341 .btn-queue-all svg {
··· 197 } 198 199 .subtitle { 200 + font-size: var(--text-base); 201 color: var(--text-tertiary); 202 margin: 0; 203 text-shadow: var(--text-shadow, none); ··· 211 background: var(--glass-btn-bg, transparent); 212 border: 1px solid var(--glass-btn-border, var(--accent)); 213 color: var(--accent); 214 + border-radius: var(--radius-base); 215 + font-size: var(--text-base); 216 font-family: inherit; 217 cursor: pointer; 218 transition: all 0.2s; ··· 240 } 241 242 .empty-state h2 { 243 + font-size: var(--text-3xl); 244 font-weight: 600; 245 color: var(--text-secondary); 246 margin: 0 0 0.5rem 0; 247 } 248 249 .empty-state p { 250 + font-size: var(--text-base); 251 margin: 0; 252 } 253 ··· 264 text-align: center; 265 padding: 4rem 1rem; 266 color: var(--text-tertiary); 267 + font-size: var(--text-base); 268 } 269 270 .tracks-list { ··· 292 } 293 294 .empty-state h2 { 295 + font-size: var(--text-2xl); 296 } 297 298 .btn-queue-all { 299 padding: 0.5rem 0.75rem; 300 + font-size: var(--text-sm); 301 } 302 303 .btn-queue-all svg { ··· 330 } 331 332 .subtitle { 333 + font-size: var(--text-sm); 334 } 335 336 .btn-queue-all { 337 padding: 0.45rem 0.65rem; 338 + font-size: var(--text-sm); 339 } 340 341 .btn-queue-all svg {
+89 -48
frontend/src/routes/track/[id]/+page.svelte
··· 12 import { checkImageSensitive } from '$lib/moderation.svelte'; 13 import { player } from '$lib/player.svelte'; 14 import { queue } from '$lib/queue.svelte'; 15 - import { playTrack } from '$lib/playback.svelte'; 16 import { auth } from '$lib/auth.svelte'; 17 import { toast } from '$lib/toast.svelte'; 18 import type { Track } from '$lib/types'; ··· 120 } 121 122 function addToQueue() { 123 queue.addTracks([track]); 124 toast.success(`queued ${track.title}`, 1800); 125 } ··· 304 305 // track which track we've loaded data for to detect navigation 306 let loadedForTrackId = $state<number | null>(null); 307 308 // reload data when navigating between track pages 309 // watch data.track.id (from server) not track.id (local state) ··· 320 newCommentText = ''; 321 editingCommentId = null; 322 editingCommentText = ''; 323 324 // sync track from server data 325 track = data.track; ··· 327 // mark as loaded for this track 328 loadedForTrackId = currentId; 329 330 - // load fresh data 331 - if (auth.isAuthenticated) { 332 - void loadLikedState(); 333 - } 334 void loadComments(); 335 } 336 }); 337 338 let shareUrl = $state(''); 339 340 $effect(() => { ··· 429 <!-- track info wrapper --> 430 <div class="track-info-wrapper"> 431 <div class="track-info"> 432 - <h1 class="track-title">{track.title}</h1> 433 <div class="track-metadata"> 434 <a href="/u/{track.artist_handle}" class="artist-link"> 435 {track.artist} ··· 481 trackTitle={track.title} 482 trackUri={track.atproto_record_uri} 483 trackCid={track.atproto_record_cid} 484 initialLiked={track.is_liked || false} 485 shareUrl={shareUrl} 486 onQueue={addToQueue} ··· 673 width: 100%; 674 max-width: 300px; 675 aspect-ratio: 1; 676 - border-radius: 8px; 677 overflow: hidden; 678 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 679 } ··· 719 margin: 0; 720 line-height: 1.2; 721 text-align: center; 722 } 723 724 .track-metadata { ··· 728 gap: 0.75rem; 729 flex-wrap: wrap; 730 color: var(--text-secondary); 731 - font-size: 1.1rem; 732 } 733 734 .separator { 735 color: var(--text-muted); 736 - font-size: 0.8rem; 737 } 738 739 .artist-link { ··· 814 815 .track-stats { 816 color: var(--text-tertiary); 817 - font-size: 0.95rem; 818 display: flex; 819 align-items: center; 820 gap: 0.5rem; ··· 822 } 823 824 .track-stats .separator { 825 - font-size: 0.7rem; 826 } 827 828 .track-tags { ··· 837 padding: 0.25rem 0.6rem; 838 background: color-mix(in srgb, var(--accent) 15%, transparent); 839 color: var(--accent-hover); 840 - border-radius: 4px; 841 - font-size: 0.85rem; 842 font-weight: 500; 843 text-decoration: none; 844 transition: all 0.15s; ··· 873 background: var(--accent); 874 color: var(--bg-primary); 875 border: none; 876 - border-radius: 24px; 877 - font-size: 0.95rem; 878 font-weight: 600; 879 font-family: inherit; 880 cursor: pointer; ··· 887 } 888 889 .btn-play.playing { 890 - opacity: 0.8; 891 } 892 893 .btn-queue { ··· 898 background: transparent; 899 color: var(--text-primary); 900 border: 1px solid var(--border-emphasis); 901 - border-radius: 24px; 902 - font-size: 0.95rem; 903 font-weight: 500; 904 font-family: inherit; 905 cursor: pointer; ··· 943 } 944 945 .track-title { 946 - font-size: 1.5rem; 947 } 948 949 .track-metadata { 950 - font-size: 0.9rem; 951 gap: 0.5rem; 952 } 953 954 .track-stats { 955 - font-size: 0.85rem; 956 } 957 958 .track-actions { ··· 969 min-width: calc(50% - 0.25rem); 970 justify-content: center; 971 padding: 0.6rem 1rem; 972 - font-size: 0.9rem; 973 } 974 975 .btn-play svg { ··· 982 min-width: calc(50% - 0.25rem); 983 justify-content: center; 984 padding: 0.6rem 1rem; 985 - font-size: 0.9rem; 986 } 987 988 .btn-queue svg { ··· 1001 } 1002 1003 .comments-title { 1004 - font-size: 1rem; 1005 font-weight: 600; 1006 color: var(--text-primary); 1007 margin: 0 0 0.75rem 0; ··· 1026 padding: 0.6rem 0.8rem; 1027 background: var(--bg-tertiary); 1028 border: 1px solid var(--border-default); 1029 - border-radius: 6px; 1030 color: var(--text-primary); 1031 - font-size: 0.9rem; 1032 font-family: inherit; 1033 } 1034 ··· 1046 background: var(--accent); 1047 color: var(--bg-primary); 1048 border: none; 1049 - border-radius: 6px; 1050 - font-size: 0.9rem; 1051 font-weight: 600; 1052 font-family: inherit; 1053 cursor: pointer; ··· 1065 1066 .login-prompt { 1067 color: var(--text-tertiary); 1068 - font-size: 0.9rem; 1069 margin-bottom: 1rem; 1070 } 1071 ··· 1080 1081 .no-comments { 1082 color: var(--text-muted); 1083 - font-size: 0.9rem; 1084 text-align: center; 1085 padding: 1rem; 1086 } ··· 1101 1102 .comments-list::-webkit-scrollbar-track { 1103 background: var(--bg-primary); 1104 - border-radius: 4px; 1105 } 1106 1107 .comments-list::-webkit-scrollbar-thumb { 1108 background: var(--border-default); 1109 - border-radius: 4px; 1110 } 1111 1112 .comments-list::-webkit-scrollbar-thumb:hover { ··· 1119 gap: 0.6rem; 1120 padding: 0.5rem 0.6rem; 1121 background: var(--bg-tertiary); 1122 - border-radius: 6px; 1123 transition: background 0.15s; 1124 } 1125 ··· 1128 } 1129 1130 .comment-timestamp { 1131 - font-size: 0.8rem; 1132 font-weight: 600; 1133 color: var(--accent); 1134 background: color-mix(in srgb, var(--accent) 10%, transparent); 1135 padding: 0.2rem 0.5rem; 1136 - border-radius: 4px; 1137 white-space: nowrap; 1138 height: fit-content; 1139 border: none; ··· 1166 } 1167 1168 .comment-time { 1169 - font-size: 0.75rem; 1170 color: var(--text-muted); 1171 } 1172 1173 .comment-avatar { 1174 width: 20px; 1175 height: 20px; 1176 - border-radius: 50%; 1177 object-fit: cover; 1178 } 1179 1180 .comment-avatar-placeholder { 1181 width: 20px; 1182 height: 20px; 1183 - border-radius: 50%; 1184 background: var(--border-default); 1185 } 1186 1187 .comment-author { 1188 - font-size: 0.85rem; 1189 font-weight: 500; 1190 color: var(--text-secondary); 1191 text-decoration: none; ··· 1196 } 1197 1198 .comment-text { 1199 - font-size: 0.9rem; 1200 color: var(--text-primary); 1201 margin: 0; 1202 line-height: 1.4; ··· 1236 border: none; 1237 padding: 0; 1238 color: var(--text-muted); 1239 - font-size: 0.8rem; 1240 cursor: pointer; 1241 transition: color 0.15s; 1242 font-family: inherit; ··· 1269 padding: 0.5rem; 1270 background: var(--bg-primary); 1271 border: 1px solid var(--border-default); 1272 - border-radius: 4px; 1273 color: var(--text-primary); 1274 - font-size: 0.9rem; 1275 font-family: inherit; 1276 } 1277 ··· 1288 1289 .edit-form-btn { 1290 padding: 0.25rem 0.6rem; 1291 - font-size: 0.8rem; 1292 font-family: inherit; 1293 - border-radius: 4px; 1294 cursor: pointer; 1295 transition: all 0.15s; 1296 } ··· 1340 ); 1341 background-size: 200% 100%; 1342 animation: shimmer 1.5s ease-in-out infinite; 1343 - border-radius: 4px; 1344 } 1345 1346 .comment-timestamp-skeleton { ··· 1352 .comment-avatar-skeleton { 1353 width: 20px; 1354 height: 20px; 1355 - border-radius: 50%; 1356 } 1357 1358 .comment-author-skeleton { ··· 1402 } 1403 1404 .comment-timestamp { 1405 - font-size: 0.75rem; 1406 padding: 0.15rem 0.4rem; 1407 } 1408 }
··· 12 import { checkImageSensitive } from '$lib/moderation.svelte'; 13 import { player } from '$lib/player.svelte'; 14 import { queue } from '$lib/queue.svelte'; 15 + import { playTrack, guardGatedTrack } from '$lib/playback.svelte'; 16 import { auth } from '$lib/auth.svelte'; 17 import { toast } from '$lib/toast.svelte'; 18 import type { Track } from '$lib/types'; ··· 120 } 121 122 function addToQueue() { 123 + if (!guardGatedTrack(track, auth.isAuthenticated)) return; 124 queue.addTracks([track]); 125 toast.success(`queued ${track.title}`, 1800); 126 } ··· 305 306 // track which track we've loaded data for to detect navigation 307 let loadedForTrackId = $state<number | null>(null); 308 + // track if we've loaded liked state for this track (separate from general load) 309 + let likedStateLoadedForTrackId = $state<number | null>(null); 310 311 // reload data when navigating between track pages 312 // watch data.track.id (from server) not track.id (local state) ··· 323 newCommentText = ''; 324 editingCommentId = null; 325 editingCommentText = ''; 326 + likedStateLoadedForTrackId = null; // reset liked state tracking 327 328 // sync track from server data 329 track = data.track; ··· 331 // mark as loaded for this track 332 loadedForTrackId = currentId; 333 334 + // load comments (doesn't require auth) 335 void loadComments(); 336 } 337 }); 338 339 + // separate effect to load liked state when auth becomes available 340 + $effect(() => { 341 + const currentId = data.track?.id; 342 + if (!currentId || !browser) return; 343 + 344 + // load liked state when authenticated and haven't loaded for this track yet 345 + if (auth.isAuthenticated && likedStateLoadedForTrackId !== currentId) { 346 + likedStateLoadedForTrackId = currentId; 347 + void loadLikedState(); 348 + } 349 + }); 350 + 351 let shareUrl = $state(''); 352 353 $effect(() => { ··· 442 <!-- track info wrapper --> 443 <div class="track-info-wrapper"> 444 <div class="track-info"> 445 + <h1 class="track-title"> 446 + {track.title} 447 + {#if track.gated} 448 + <span class="gated-badge" title="supporters only"> 449 + <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> 450 + <path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/> 451 + </svg> 452 + </span> 453 + {/if} 454 + </h1> 455 <div class="track-metadata"> 456 <a href="/u/{track.artist_handle}" class="artist-link"> 457 {track.artist} ··· 503 trackTitle={track.title} 504 trackUri={track.atproto_record_uri} 505 trackCid={track.atproto_record_cid} 506 + fileId={track.file_id} 507 + gated={track.gated} 508 initialLiked={track.is_liked || false} 509 shareUrl={shareUrl} 510 onQueue={addToQueue} ··· 697 width: 100%; 698 max-width: 300px; 699 aspect-ratio: 1; 700 + border-radius: var(--radius-md); 701 overflow: hidden; 702 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 703 } ··· 743 margin: 0; 744 line-height: 1.2; 745 text-align: center; 746 + display: inline-flex; 747 + align-items: center; 748 + justify-content: center; 749 + gap: 0.5rem; 750 + flex-wrap: wrap; 751 + } 752 + 753 + .gated-badge { 754 + display: inline-flex; 755 + align-items: center; 756 + justify-content: center; 757 + color: var(--accent); 758 + opacity: 0.8; 759 + } 760 + 761 + .gated-badge svg { 762 + display: block; 763 } 764 765 .track-metadata { ··· 769 gap: 0.75rem; 770 flex-wrap: wrap; 771 color: var(--text-secondary); 772 + font-size: var(--text-xl); 773 } 774 775 .separator { 776 color: var(--text-muted); 777 + font-size: var(--text-sm); 778 } 779 780 .artist-link { ··· 855 856 .track-stats { 857 color: var(--text-tertiary); 858 + font-size: var(--text-base); 859 display: flex; 860 align-items: center; 861 gap: 0.5rem; ··· 863 } 864 865 .track-stats .separator { 866 + font-size: var(--text-xs); 867 } 868 869 .track-tags { ··· 878 padding: 0.25rem 0.6rem; 879 background: color-mix(in srgb, var(--accent) 15%, transparent); 880 color: var(--accent-hover); 881 + border-radius: var(--radius-sm); 882 + font-size: var(--text-sm); 883 font-weight: 500; 884 text-decoration: none; 885 transition: all 0.15s; ··· 914 background: var(--accent); 915 color: var(--bg-primary); 916 border: none; 917 + border-radius: var(--radius-2xl); 918 + font-size: var(--text-base); 919 font-weight: 600; 920 font-family: inherit; 921 cursor: pointer; ··· 928 } 929 930 .btn-play.playing { 931 + animation: ethereal-glow 3s ease-in-out infinite; 932 } 933 934 .btn-queue { ··· 939 background: transparent; 940 color: var(--text-primary); 941 border: 1px solid var(--border-emphasis); 942 + border-radius: var(--radius-2xl); 943 + font-size: var(--text-base); 944 font-weight: 500; 945 font-family: inherit; 946 cursor: pointer; ··· 984 } 985 986 .track-title { 987 + font-size: var(--text-3xl); 988 } 989 990 .track-metadata { 991 + font-size: var(--text-base); 992 gap: 0.5rem; 993 } 994 995 .track-stats { 996 + font-size: var(--text-sm); 997 } 998 999 .track-actions { ··· 1010 min-width: calc(50% - 0.25rem); 1011 justify-content: center; 1012 padding: 0.6rem 1rem; 1013 + font-size: var(--text-base); 1014 } 1015 1016 .btn-play svg { ··· 1023 min-width: calc(50% - 0.25rem); 1024 justify-content: center; 1025 padding: 0.6rem 1rem; 1026 + font-size: var(--text-base); 1027 } 1028 1029 .btn-queue svg { ··· 1042 } 1043 1044 .comments-title { 1045 + font-size: var(--text-lg); 1046 font-weight: 600; 1047 color: var(--text-primary); 1048 margin: 0 0 0.75rem 0; ··· 1067 padding: 0.6rem 0.8rem; 1068 background: var(--bg-tertiary); 1069 border: 1px solid var(--border-default); 1070 + border-radius: var(--radius-base); 1071 color: var(--text-primary); 1072 + font-size: var(--text-base); 1073 font-family: inherit; 1074 } 1075 ··· 1087 background: var(--accent); 1088 color: var(--bg-primary); 1089 border: none; 1090 + border-radius: var(--radius-base); 1091 + font-size: var(--text-base); 1092 font-weight: 600; 1093 font-family: inherit; 1094 cursor: pointer; ··· 1106 1107 .login-prompt { 1108 color: var(--text-tertiary); 1109 + font-size: var(--text-base); 1110 margin-bottom: 1rem; 1111 } 1112 ··· 1121 1122 .no-comments { 1123 color: var(--text-muted); 1124 + font-size: var(--text-base); 1125 text-align: center; 1126 padding: 1rem; 1127 } ··· 1142 1143 .comments-list::-webkit-scrollbar-track { 1144 background: var(--bg-primary); 1145 + border-radius: var(--radius-sm); 1146 } 1147 1148 .comments-list::-webkit-scrollbar-thumb { 1149 background: var(--border-default); 1150 + border-radius: var(--radius-sm); 1151 } 1152 1153 .comments-list::-webkit-scrollbar-thumb:hover { ··· 1160 gap: 0.6rem; 1161 padding: 0.5rem 0.6rem; 1162 background: var(--bg-tertiary); 1163 + border-radius: var(--radius-base); 1164 transition: background 0.15s; 1165 } 1166 ··· 1169 } 1170 1171 .comment-timestamp { 1172 + font-size: var(--text-sm); 1173 font-weight: 600; 1174 color: var(--accent); 1175 background: color-mix(in srgb, var(--accent) 10%, transparent); 1176 padding: 0.2rem 0.5rem; 1177 + border-radius: var(--radius-sm); 1178 white-space: nowrap; 1179 height: fit-content; 1180 border: none; ··· 1207 } 1208 1209 .comment-time { 1210 + font-size: var(--text-xs); 1211 color: var(--text-muted); 1212 } 1213 1214 .comment-avatar { 1215 width: 20px; 1216 height: 20px; 1217 + border-radius: var(--radius-full); 1218 object-fit: cover; 1219 } 1220 1221 .comment-avatar-placeholder { 1222 width: 20px; 1223 height: 20px; 1224 + border-radius: var(--radius-full); 1225 background: var(--border-default); 1226 } 1227 1228 .comment-author { 1229 + font-size: var(--text-sm); 1230 font-weight: 500; 1231 color: var(--text-secondary); 1232 text-decoration: none; ··· 1237 } 1238 1239 .comment-text { 1240 + font-size: var(--text-base); 1241 color: var(--text-primary); 1242 margin: 0; 1243 line-height: 1.4; ··· 1277 border: none; 1278 padding: 0; 1279 color: var(--text-muted); 1280 + font-size: var(--text-sm); 1281 cursor: pointer; 1282 transition: color 0.15s; 1283 font-family: inherit; ··· 1310 padding: 0.5rem; 1311 background: var(--bg-primary); 1312 border: 1px solid var(--border-default); 1313 + border-radius: var(--radius-sm); 1314 color: var(--text-primary); 1315 + font-size: var(--text-base); 1316 font-family: inherit; 1317 } 1318 ··· 1329 1330 .edit-form-btn { 1331 padding: 0.25rem 0.6rem; 1332 + font-size: var(--text-sm); 1333 font-family: inherit; 1334 + border-radius: var(--radius-sm); 1335 cursor: pointer; 1336 transition: all 0.15s; 1337 } ··· 1381 ); 1382 background-size: 200% 100%; 1383 animation: shimmer 1.5s ease-in-out infinite; 1384 + border-radius: var(--radius-sm); 1385 } 1386 1387 .comment-timestamp-skeleton { ··· 1393 .comment-avatar-skeleton { 1394 width: 20px; 1395 height: 20px; 1396 + border-radius: var(--radius-full); 1397 } 1398 1399 .comment-author-skeleton { ··· 1443 } 1444 1445 .comment-timestamp { 1446 + font-size: var(--text-xs); 1447 padding: 0.15rem 0.4rem; 1448 } 1449 }
+5 -5
frontend/src/routes/u/[handle]/+error.svelte
··· 96 } 97 98 .error-message { 99 - font-size: 1.25rem; 100 color: var(--text-secondary); 101 margin: 0 0 0.5rem 0; 102 } 103 104 .error-detail { 105 - font-size: 1rem; 106 color: var(--text-tertiary); 107 margin: 0 0 2rem 0; 108 } ··· 118 .bsky-link { 119 color: var(--accent); 120 text-decoration: none; 121 - font-size: 1.1rem; 122 padding: 0.75rem 1.5rem; 123 border: 1px solid var(--accent); 124 - border-radius: 6px; 125 transition: all 0.2s; 126 display: inline-block; 127 } ··· 151 } 152 153 .error-message { 154 - font-size: 1.1rem; 155 } 156 157 .actions {
··· 96 } 97 98 .error-message { 99 + font-size: var(--text-2xl); 100 color: var(--text-secondary); 101 margin: 0 0 0.5rem 0; 102 } 103 104 .error-detail { 105 + font-size: var(--text-lg); 106 color: var(--text-tertiary); 107 margin: 0 0 2rem 0; 108 } ··· 118 .bsky-link { 119 color: var(--accent); 120 text-decoration: none; 121 + font-size: var(--text-xl); 122 padding: 0.75rem 1.5rem; 123 border: 1px solid var(--accent); 124 + border-radius: var(--radius-base); 125 transition: all 0.2s; 126 display: inline-block; 127 } ··· 151 } 152 153 .error-message { 154 + font-size: var(--text-xl); 155 } 156 157 .actions {
+33 -29
frontend/src/routes/u/[handle]/+page.svelte
··· 613 padding: 2rem; 614 background: var(--bg-secondary); 615 border: 1px solid var(--border-subtle); 616 - border-radius: 8px; 617 } 618 619 .artist-details { ··· 651 padding: 0 0.75rem; 652 background: color-mix(in srgb, var(--accent) 15%, transparent); 653 border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 654 - border-radius: 4px; 655 color: var(--accent); 656 - font-size: 0.85rem; 657 text-decoration: none; 658 transition: all 0.2s ease; 659 } ··· 671 .artist-avatar { 672 width: 120px; 673 height: 120px; 674 - border-radius: 50%; 675 object-fit: cover; 676 border: 3px solid var(--border-default); 677 } ··· 695 696 .handle { 697 color: var(--text-tertiary); 698 - font-size: 1.1rem; 699 text-decoration: none; 700 transition: color 0.2s; 701 } ··· 738 739 .section-header span { 740 color: var(--text-tertiary); 741 - font-size: 0.9rem; 742 text-transform: uppercase; 743 letter-spacing: 0.1em; 744 } ··· 756 padding: 1rem; 757 background: var(--bg-secondary); 758 border: 1px solid var(--border-subtle); 759 - border-radius: 10px; 760 color: inherit; 761 text-decoration: none; 762 transition: transform 0.15s ease, border-color 0.15s ease; ··· 772 .album-cover-wrapper { 773 width: 72px; 774 height: 72px; 775 - border-radius: 6px; 776 overflow: hidden; 777 flex-shrink: 0; 778 background: var(--bg-tertiary); ··· 820 .album-card-meta p { 821 margin: 0; 822 color: var(--text-tertiary); 823 - font-size: 0.9rem; 824 display: flex; 825 align-items: center; 826 gap: 0.4rem; ··· 834 .stat-card { 835 background: var(--bg-secondary); 836 border: 1px solid var(--border-subtle); 837 - border-radius: 8px; 838 padding: 1.5rem; 839 transition: border-color 0.2s; 840 } ··· 853 854 .stat-label { 855 color: var(--text-tertiary); 856 - font-size: 0.9rem; 857 text-transform: lowercase; 858 line-height: 1; 859 } 860 861 .stat-duration { 862 margin-top: 0.5rem; 863 - font-size: 0.85rem; 864 color: var(--text-secondary); 865 font-variant-numeric: tabular-nums; 866 } ··· 888 889 .top-item-plays { 890 color: var(--accent); 891 - font-size: 1rem; 892 line-height: 1; 893 } 894 ··· 908 ); 909 background-size: 200% 100%; 910 animation: shimmer 1.5s ease-in-out infinite; 911 - border-radius: 4px; 912 } 913 914 /* match .stat-value dimensions: 2.5rem font + 0.5rem margin-bottom */ ··· 960 padding: 0.75rem 1.5rem; 961 background: var(--bg-secondary); 962 border: 1px solid var(--border-subtle); 963 - border-radius: 8px; 964 color: var(--text-secondary); 965 font-family: inherit; 966 - font-size: 0.95rem; 967 cursor: pointer; 968 transition: all 0.2s ease; 969 } ··· 981 982 .tracks-loading { 983 margin-left: 0.75rem; 984 - font-size: 0.95rem; 985 color: var(--text-secondary); 986 font-weight: 400; 987 text-transform: lowercase; ··· 998 padding: 3rem; 999 background: var(--bg-secondary); 1000 border: 1px solid var(--border-subtle); 1001 - border-radius: 8px; 1002 } 1003 1004 .empty-message { 1005 color: var(--text-secondary); 1006 - font-size: 1.25rem; 1007 margin: 0 0 0.5rem 0; 1008 } 1009 ··· 1015 .bsky-link { 1016 color: var(--accent); 1017 text-decoration: none; 1018 - font-size: 1rem; 1019 padding: 0.75rem 1.5rem; 1020 border: 1px solid var(--accent); 1021 - border-radius: 6px; 1022 transition: all 0.2s; 1023 display: inline-block; 1024 } ··· 1058 text-align: center; 1059 } 1060 1061 .artist-actions-desktop { 1062 display: none; 1063 } ··· 1068 1069 .support-btn { 1070 height: 28px; 1071 - font-size: 0.8rem; 1072 padding: 0 0.6rem; 1073 } 1074 ··· 1108 .album-cover-wrapper { 1109 width: 56px; 1110 height: 56px; 1111 - border-radius: 4px; 1112 } 1113 1114 .album-card-meta h3 { 1115 - font-size: 0.95rem; 1116 margin-bottom: 0.25rem; 1117 } 1118 1119 .album-card-meta p { 1120 - font-size: 0.8rem; 1121 } 1122 } 1123 ··· 1144 padding: 1.25rem 1.5rem; 1145 background: var(--bg-secondary); 1146 border: 1px solid var(--border-subtle); 1147 - border-radius: 10px; 1148 color: inherit; 1149 text-decoration: none; 1150 transition: transform 0.15s ease, border-color 0.15s ease; ··· 1158 .collection-icon { 1159 width: 48px; 1160 height: 48px; 1161 - border-radius: 8px; 1162 display: flex; 1163 align-items: center; 1164 justify-content: center; ··· 1189 1190 .collection-info h3 { 1191 margin: 0 0 0.25rem 0; 1192 - font-size: 1.1rem; 1193 color: var(--text-primary); 1194 } 1195 1196 .collection-info p { 1197 margin: 0; 1198 - font-size: 0.9rem; 1199 color: var(--text-tertiary); 1200 } 1201
··· 613 padding: 2rem; 614 background: var(--bg-secondary); 615 border: 1px solid var(--border-subtle); 616 + border-radius: var(--radius-md); 617 } 618 619 .artist-details { ··· 651 padding: 0 0.75rem; 652 background: color-mix(in srgb, var(--accent) 15%, transparent); 653 border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 654 + border-radius: var(--radius-sm); 655 color: var(--accent); 656 + font-size: var(--text-sm); 657 text-decoration: none; 658 transition: all 0.2s ease; 659 } ··· 671 .artist-avatar { 672 width: 120px; 673 height: 120px; 674 + border-radius: var(--radius-full); 675 object-fit: cover; 676 border: 3px solid var(--border-default); 677 } ··· 695 696 .handle { 697 color: var(--text-tertiary); 698 + font-size: var(--text-xl); 699 text-decoration: none; 700 transition: color 0.2s; 701 } ··· 738 739 .section-header span { 740 color: var(--text-tertiary); 741 + font-size: var(--text-base); 742 text-transform: uppercase; 743 letter-spacing: 0.1em; 744 } ··· 756 padding: 1rem; 757 background: var(--bg-secondary); 758 border: 1px solid var(--border-subtle); 759 + border-radius: var(--radius-md); 760 color: inherit; 761 text-decoration: none; 762 transition: transform 0.15s ease, border-color 0.15s ease; ··· 772 .album-cover-wrapper { 773 width: 72px; 774 height: 72px; 775 + border-radius: var(--radius-base); 776 overflow: hidden; 777 flex-shrink: 0; 778 background: var(--bg-tertiary); ··· 820 .album-card-meta p { 821 margin: 0; 822 color: var(--text-tertiary); 823 + font-size: var(--text-base); 824 display: flex; 825 align-items: center; 826 gap: 0.4rem; ··· 834 .stat-card { 835 background: var(--bg-secondary); 836 border: 1px solid var(--border-subtle); 837 + border-radius: var(--radius-md); 838 padding: 1.5rem; 839 transition: border-color 0.2s; 840 } ··· 853 854 .stat-label { 855 color: var(--text-tertiary); 856 + font-size: var(--text-base); 857 text-transform: lowercase; 858 line-height: 1; 859 } 860 861 .stat-duration { 862 margin-top: 0.5rem; 863 + font-size: var(--text-sm); 864 color: var(--text-secondary); 865 font-variant-numeric: tabular-nums; 866 } ··· 888 889 .top-item-plays { 890 color: var(--accent); 891 + font-size: var(--text-lg); 892 line-height: 1; 893 } 894 ··· 908 ); 909 background-size: 200% 100%; 910 animation: shimmer 1.5s ease-in-out infinite; 911 + border-radius: var(--radius-sm); 912 } 913 914 /* match .stat-value dimensions: 2.5rem font + 0.5rem margin-bottom */ ··· 960 padding: 0.75rem 1.5rem; 961 background: var(--bg-secondary); 962 border: 1px solid var(--border-subtle); 963 + border-radius: var(--radius-md); 964 color: var(--text-secondary); 965 font-family: inherit; 966 + font-size: var(--text-base); 967 cursor: pointer; 968 transition: all 0.2s ease; 969 } ··· 981 982 .tracks-loading { 983 margin-left: 0.75rem; 984 + font-size: var(--text-base); 985 color: var(--text-secondary); 986 font-weight: 400; 987 text-transform: lowercase; ··· 998 padding: 3rem; 999 background: var(--bg-secondary); 1000 border: 1px solid var(--border-subtle); 1001 + border-radius: var(--radius-md); 1002 } 1003 1004 .empty-message { 1005 color: var(--text-secondary); 1006 + font-size: var(--text-2xl); 1007 margin: 0 0 0.5rem 0; 1008 } 1009 ··· 1015 .bsky-link { 1016 color: var(--accent); 1017 text-decoration: none; 1018 + font-size: var(--text-lg); 1019 padding: 0.75rem 1.5rem; 1020 border: 1px solid var(--accent); 1021 + border-radius: var(--radius-base); 1022 transition: all 0.2s; 1023 display: inline-block; 1024 } ··· 1058 text-align: center; 1059 } 1060 1061 + .handle-row { 1062 + justify-content: center; 1063 + } 1064 + 1065 .artist-actions-desktop { 1066 display: none; 1067 } ··· 1072 1073 .support-btn { 1074 height: 28px; 1075 + font-size: var(--text-sm); 1076 padding: 0 0.6rem; 1077 } 1078 ··· 1112 .album-cover-wrapper { 1113 width: 56px; 1114 height: 56px; 1115 + border-radius: var(--radius-sm); 1116 } 1117 1118 .album-card-meta h3 { 1119 + font-size: var(--text-base); 1120 margin-bottom: 0.25rem; 1121 } 1122 1123 .album-card-meta p { 1124 + font-size: var(--text-sm); 1125 } 1126 } 1127 ··· 1148 padding: 1.25rem 1.5rem; 1149 background: var(--bg-secondary); 1150 border: 1px solid var(--border-subtle); 1151 + border-radius: var(--radius-md); 1152 color: inherit; 1153 text-decoration: none; 1154 transition: transform 0.15s ease, border-color 0.15s ease; ··· 1162 .collection-icon { 1163 width: 48px; 1164 height: 48px; 1165 + border-radius: var(--radius-md); 1166 display: flex; 1167 align-items: center; 1168 justify-content: center; ··· 1193 1194 .collection-info h3 { 1195 margin: 0 0 0.25rem 0; 1196 + font-size: var(--text-xl); 1197 color: var(--text-primary); 1198 } 1199 1200 .collection-info p { 1201 margin: 0; 1202 + font-size: var(--text-base); 1203 color: var(--text-tertiary); 1204 } 1205
+51 -27
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 27 tracks = [...data.album.tracks]; 28 }); 29 30 // check if current user owns this album 31 const isOwner = $derived(auth.user?.did === albumMetadata.artist_did); 32 // can only reorder if owner and album has an ATProto list 33 const canReorder = $derived(isOwner && !!albumMetadata.list_uri); 34 35 - // local mutable copy of tracks for reordering 36 - let tracks = $state<Track[]>([...data.album.tracks]); 37 38 // edit mode state 39 let isEditMode = $state(false); ··· 545 </div> 546 547 <div class="album-actions"> 548 - <button class="play-button" onclick={playNow}> 549 - <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 550 - <path d="M8 5v14l11-7z"/> 551 - </svg> 552 - play now 553 </button> 554 <button class="queue-button" onclick={addToQueue}> 555 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> ··· 766 .album-art { 767 width: 200px; 768 height: 200px; 769 - border-radius: 8px; 770 object-fit: cover; 771 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 772 } ··· 774 .album-art-placeholder { 775 width: 200px; 776 height: 200px; 777 - border-radius: 8px; 778 background: var(--bg-tertiary); 779 border: 1px solid var(--border-subtle); 780 display: flex; ··· 817 height: 32px; 818 background: transparent; 819 border: 1px solid var(--border-default); 820 - border-radius: 4px; 821 color: var(--text-tertiary); 822 cursor: pointer; 823 transition: all 0.15s; ··· 858 position: absolute; 859 inset: 0; 860 background: rgba(0, 0, 0, 0.6); 861 - border-radius: 8px; 862 display: flex; 863 flex-direction: column; 864 align-items: center; 865 justify-content: center; 866 gap: 0.5rem; 867 color: white; 868 - font-size: 0.85rem; 869 opacity: 0; 870 transition: opacity 0.2s; 871 } ··· 893 894 .album-type { 895 text-transform: uppercase; 896 - font-size: 0.75rem; 897 font-weight: 600; 898 letter-spacing: 0.1em; 899 color: var(--text-tertiary); ··· 917 display: flex; 918 align-items: center; 919 gap: 0.75rem; 920 - font-size: 0.95rem; 921 color: var(--text-secondary); 922 text-shadow: var(--text-shadow, none); 923 } ··· 936 937 .meta-separator { 938 color: var(--text-muted); 939 - font-size: 0.7rem; 940 } 941 942 .album-actions { ··· 948 .play-button, 949 .queue-button { 950 padding: 0.75rem 1.5rem; 951 - border-radius: 24px; 952 font-weight: 600; 953 - font-size: 0.95rem; 954 font-family: inherit; 955 cursor: pointer; 956 transition: all 0.2s; ··· 969 transform: scale(1.05); 970 } 971 972 .queue-button { 973 background: var(--glass-btn-bg, transparent); 974 color: var(--text-primary); ··· 1000 } 1001 1002 .section-heading { 1003 - font-size: 1.25rem; 1004 font-weight: 600; 1005 color: var(--text-primary); 1006 margin-bottom: 1rem; ··· 1018 display: flex; 1019 align-items: center; 1020 gap: 0.5rem; 1021 - border-radius: 8px; 1022 transition: all 0.2s; 1023 position: relative; 1024 } ··· 1050 color: var(--text-muted); 1051 cursor: grab; 1052 touch-action: none; 1053 - border-radius: 4px; 1054 transition: all 0.2s; 1055 flex-shrink: 0; 1056 } ··· 1111 } 1112 1113 .album-meta { 1114 - font-size: 0.85rem; 1115 } 1116 1117 .album-actions { ··· 1143 } 1144 1145 .album-meta { 1146 - font-size: 0.8rem; 1147 flex-wrap: wrap; 1148 } 1149 } ··· 1155 justify-content: center; 1156 width: 32px; 1157 height: 32px; 1158 - border-radius: 50%; 1159 background: transparent; 1160 border: none; 1161 color: var(--text-muted); ··· 1188 1189 .modal { 1190 background: var(--bg-secondary); 1191 - border-radius: 12px; 1192 padding: 1.5rem; 1193 max-width: 400px; 1194 width: calc(100% - 2rem); ··· 1202 1203 .modal-header h3 { 1204 margin: 0; 1205 - font-size: 1.25rem; 1206 font-weight: 600; 1207 color: var(--text-primary); 1208 } ··· 1226 .cancel-btn, 1227 .confirm-btn { 1228 padding: 0.625rem 1.25rem; 1229 - border-radius: 8px; 1230 font-weight: 500; 1231 - font-size: 0.9rem; 1232 font-family: inherit; 1233 cursor: pointer; 1234 transition: all 0.2s;
··· 27 tracks = [...data.album.tracks]; 28 }); 29 30 + // local mutable copy of tracks for reordering 31 + let tracks = $state<Track[]>([...data.album.tracks]); 32 + 33 // check if current user owns this album 34 const isOwner = $derived(auth.user?.did === albumMetadata.artist_did); 35 // can only reorder if owner and album has an ATProto list 36 const canReorder = $derived(isOwner && !!albumMetadata.list_uri); 37 38 + // check if current track is from this album (active, regardless of paused state) 39 + const isAlbumActive = $derived( 40 + player.currentTrack !== null && 41 + tracks.some(t => t.id === player.currentTrack?.id) 42 + ); 43 + 44 + // check if actively playing (not paused) 45 + const isAlbumPlaying = $derived(isAlbumActive && !player.paused); 46 47 // edit mode state 48 let isEditMode = $state(false); ··· 554 </div> 555 556 <div class="album-actions"> 557 + <button 558 + class="play-button" 559 + class:is-playing={isAlbumPlaying} 560 + onclick={() => isAlbumActive ? player.togglePlayPause() : playNow()} 561 + > 562 + {#if isAlbumPlaying} 563 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 564 + <path d="M6 4h4v16H6zM14 4h4v16h-4z"/> 565 + </svg> 566 + pause 567 + {:else} 568 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 569 + <path d="M8 5v14l11-7z"/> 570 + </svg> 571 + play 572 + {/if} 573 </button> 574 <button class="queue-button" onclick={addToQueue}> 575 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> ··· 786 .album-art { 787 width: 200px; 788 height: 200px; 789 + border-radius: var(--radius-md); 790 object-fit: cover; 791 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 792 } ··· 794 .album-art-placeholder { 795 width: 200px; 796 height: 200px; 797 + border-radius: var(--radius-md); 798 background: var(--bg-tertiary); 799 border: 1px solid var(--border-subtle); 800 display: flex; ··· 837 height: 32px; 838 background: transparent; 839 border: 1px solid var(--border-default); 840 + border-radius: var(--radius-sm); 841 color: var(--text-tertiary); 842 cursor: pointer; 843 transition: all 0.15s; ··· 878 position: absolute; 879 inset: 0; 880 background: rgba(0, 0, 0, 0.6); 881 + border-radius: var(--radius-md); 882 display: flex; 883 flex-direction: column; 884 align-items: center; 885 justify-content: center; 886 gap: 0.5rem; 887 color: white; 888 + font-size: var(--text-sm); 889 opacity: 0; 890 transition: opacity 0.2s; 891 } ··· 913 914 .album-type { 915 text-transform: uppercase; 916 + font-size: var(--text-xs); 917 font-weight: 600; 918 letter-spacing: 0.1em; 919 color: var(--text-tertiary); ··· 937 display: flex; 938 align-items: center; 939 gap: 0.75rem; 940 + font-size: var(--text-base); 941 color: var(--text-secondary); 942 text-shadow: var(--text-shadow, none); 943 } ··· 956 957 .meta-separator { 958 color: var(--text-muted); 959 + font-size: var(--text-xs); 960 } 961 962 .album-actions { ··· 968 .play-button, 969 .queue-button { 970 padding: 0.75rem 1.5rem; 971 + border-radius: var(--radius-2xl); 972 font-weight: 600; 973 + font-size: var(--text-base); 974 font-family: inherit; 975 cursor: pointer; 976 transition: all 0.2s; ··· 989 transform: scale(1.05); 990 } 991 992 + .play-button.is-playing { 993 + animation: ethereal-glow 3s ease-in-out infinite; 994 + } 995 + 996 .queue-button { 997 background: var(--glass-btn-bg, transparent); 998 color: var(--text-primary); ··· 1024 } 1025 1026 .section-heading { 1027 + font-size: var(--text-2xl); 1028 font-weight: 600; 1029 color: var(--text-primary); 1030 margin-bottom: 1rem; ··· 1042 display: flex; 1043 align-items: center; 1044 gap: 0.5rem; 1045 + border-radius: var(--radius-md); 1046 transition: all 0.2s; 1047 position: relative; 1048 } ··· 1074 color: var(--text-muted); 1075 cursor: grab; 1076 touch-action: none; 1077 + border-radius: var(--radius-sm); 1078 transition: all 0.2s; 1079 flex-shrink: 0; 1080 } ··· 1135 } 1136 1137 .album-meta { 1138 + font-size: var(--text-sm); 1139 } 1140 1141 .album-actions { ··· 1167 } 1168 1169 .album-meta { 1170 + font-size: var(--text-sm); 1171 flex-wrap: wrap; 1172 } 1173 } ··· 1179 justify-content: center; 1180 width: 32px; 1181 height: 32px; 1182 + border-radius: var(--radius-full); 1183 background: transparent; 1184 border: none; 1185 color: var(--text-muted); ··· 1212 1213 .modal { 1214 background: var(--bg-secondary); 1215 + border-radius: var(--radius-lg); 1216 padding: 1.5rem; 1217 max-width: 400px; 1218 width: calc(100% - 2rem); ··· 1226 1227 .modal-header h3 { 1228 margin: 0; 1229 + font-size: var(--text-2xl); 1230 font-weight: 600; 1231 color: var(--text-primary); 1232 } ··· 1250 .cancel-btn, 1251 .confirm-btn { 1252 padding: 0.625rem 1.25rem; 1253 + border-radius: var(--radius-md); 1254 font-weight: 500; 1255 + font-size: var(--text-base); 1256 font-family: inherit; 1257 cursor: pointer; 1258 transition: all 0.2s;
+19 -18
frontend/src/routes/upload/+page.svelte
··· 332 </svg> 333 </span> 334 <span class="gating-disabled-text"> 335 - want to gate tracks for supporters? <a href="https://atprotofans.com" target="_blank" rel="noopener">set up atprotofans</a>, then enable it in your <a href="/portal">portal</a> 336 </span> 337 </div> 338 {/if} ··· 379 > 380 <span>upload track</span> 381 </button> 382 </form> 383 </main> 384 {/if} ··· 422 form { 423 background: var(--bg-tertiary); 424 padding: 2rem; 425 - border-radius: 8px; 426 border: 1px solid var(--border-subtle); 427 } 428 ··· 434 display: block; 435 color: var(--text-secondary); 436 margin-bottom: 0.5rem; 437 - font-size: 0.9rem; 438 } 439 440 input[type="text"] { ··· 442 padding: 0.75rem; 443 background: var(--bg-primary); 444 border: 1px solid var(--border-default); 445 - border-radius: 4px; 446 color: var(--text-primary); 447 - font-size: 1rem; 448 font-family: inherit; 449 transition: all 0.2s; 450 } ··· 459 padding: 0.75rem; 460 background: var(--bg-primary); 461 border: 1px solid var(--border-default); 462 - border-radius: 4px; 463 color: var(--text-primary); 464 - font-size: 0.9rem; 465 font-family: inherit; 466 cursor: pointer; 467 } 468 469 .format-hint { 470 margin-top: 0.25rem; 471 - font-size: 0.8rem; 472 color: var(--text-tertiary); 473 } 474 475 .file-info { 476 margin-top: 0.5rem; 477 - font-size: 0.85rem; 478 color: var(--text-muted); 479 } 480 ··· 484 background: var(--accent); 485 color: var(--text-primary); 486 border: none; 487 - border-radius: 4px; 488 - font-size: 1rem; 489 font-weight: 600; 490 font-family: inherit; 491 cursor: pointer; ··· 519 .attestation { 520 background: var(--bg-primary); 521 padding: 1rem; 522 - border-radius: 4px; 523 border: 1px solid var(--border-default); 524 } 525 ··· 541 } 542 543 .checkbox-text { 544 - font-size: 0.95rem; 545 color: var(--text-primary); 546 line-height: 1.4; 547 } ··· 549 .attestation-note { 550 margin-top: 0.75rem; 551 margin-left: 2rem; 552 - font-size: 0.8rem; 553 color: var(--text-tertiary); 554 line-height: 1.4; 555 } ··· 566 .supporter-gating { 567 background: color-mix(in srgb, var(--accent) 8%, var(--bg-primary)); 568 padding: 1rem; 569 - border-radius: 4px; 570 border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-default)); 571 } 572 ··· 583 .gating-note { 584 margin-top: 0.5rem; 585 margin-left: 2rem; 586 - font-size: 0.8rem; 587 color: var(--text-tertiary); 588 line-height: 1.4; 589 } ··· 610 } 611 612 .gating-disabled-text { 613 - font-size: 0.85rem; 614 line-height: 1.4; 615 } 616 ··· 637 } 638 639 .section-header h2 { 640 - font-size: 1.25rem; 641 } 642 } 643 </style>
··· 332 </svg> 333 </span> 334 <span class="gating-disabled-text"> 335 + want to offer exclusive tracks to supporters? <a href="https://atprotofans.com" target="_blank" rel="noopener">set up atprotofans</a>, then enable it in your <a href="/portal">portal</a> 336 </span> 337 </div> 338 {/if} ··· 379 > 380 <span>upload track</span> 381 </button> 382 + 383 </form> 384 </main> 385 {/if} ··· 423 form { 424 background: var(--bg-tertiary); 425 padding: 2rem; 426 + border-radius: var(--radius-md); 427 border: 1px solid var(--border-subtle); 428 } 429 ··· 435 display: block; 436 color: var(--text-secondary); 437 margin-bottom: 0.5rem; 438 + font-size: var(--text-base); 439 } 440 441 input[type="text"] { ··· 443 padding: 0.75rem; 444 background: var(--bg-primary); 445 border: 1px solid var(--border-default); 446 + border-radius: var(--radius-sm); 447 color: var(--text-primary); 448 + font-size: var(--text-lg); 449 font-family: inherit; 450 transition: all 0.2s; 451 } ··· 460 padding: 0.75rem; 461 background: var(--bg-primary); 462 border: 1px solid var(--border-default); 463 + border-radius: var(--radius-sm); 464 color: var(--text-primary); 465 + font-size: var(--text-base); 466 font-family: inherit; 467 cursor: pointer; 468 } 469 470 .format-hint { 471 margin-top: 0.25rem; 472 + font-size: var(--text-sm); 473 color: var(--text-tertiary); 474 } 475 476 .file-info { 477 margin-top: 0.5rem; 478 + font-size: var(--text-sm); 479 color: var(--text-muted); 480 } 481 ··· 485 background: var(--accent); 486 color: var(--text-primary); 487 border: none; 488 + border-radius: var(--radius-sm); 489 + font-size: var(--text-lg); 490 font-weight: 600; 491 font-family: inherit; 492 cursor: pointer; ··· 520 .attestation { 521 background: var(--bg-primary); 522 padding: 1rem; 523 + border-radius: var(--radius-sm); 524 border: 1px solid var(--border-default); 525 } 526 ··· 542 } 543 544 .checkbox-text { 545 + font-size: var(--text-base); 546 color: var(--text-primary); 547 line-height: 1.4; 548 } ··· 550 .attestation-note { 551 margin-top: 0.75rem; 552 margin-left: 2rem; 553 + font-size: var(--text-sm); 554 color: var(--text-tertiary); 555 line-height: 1.4; 556 } ··· 567 .supporter-gating { 568 background: color-mix(in srgb, var(--accent) 8%, var(--bg-primary)); 569 padding: 1rem; 570 + border-radius: var(--radius-sm); 571 border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-default)); 572 } 573 ··· 584 .gating-note { 585 margin-top: 0.5rem; 586 margin-left: 2rem; 587 + font-size: var(--text-sm); 588 color: var(--text-tertiary); 589 line-height: 1.4; 590 } ··· 611 } 612 613 .gating-disabled-text { 614 + font-size: var(--text-sm); 615 line-height: 1.4; 616 } 617 ··· 638 } 639 640 .section-header h2 { 641 + font-size: var(--text-2xl); 642 } 643 } 644 </style>
+1 -1
moderation/Cargo.toml
··· 6 [dependencies] 7 anyhow = "1.0" 8 axum = { version = "0.7", features = ["macros", "json", "ws"] } 9 bytes = "1.0" 10 chrono = { version = "0.4", features = ["serde"] } 11 futures = "0.3" ··· 25 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 26 27 [dev-dependencies] 28 - rand = "0.8"
··· 6 [dependencies] 7 anyhow = "1.0" 8 axum = { version = "0.7", features = ["macros", "json", "ws"] } 9 + rand = "0.8" 10 bytes = "1.0" 11 chrono = { version = "0.4", features = ["serde"] } 12 futures = "0.3" ··· 26 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 27 28 [dev-dependencies]
+159
moderation/src/admin.rs
··· 104 pub active_uris: Vec<String>, 105 } 106 107 /// List all flagged tracks - returns JSON for API, HTML for htmx. 108 pub async fn list_flagged( 109 State(state): State<AppState>, ··· 292 Ok(Json(StoreContextResponse { 293 message: format!("context stored for {}", request.uri), 294 })) 295 } 296 297 /// Serve the admin UI HTML from static file.
··· 104 pub active_uris: Vec<String>, 105 } 106 107 + /// Request to add a sensitive image. 108 + #[derive(Debug, Deserialize)] 109 + pub struct AddSensitiveImageRequest { 110 + /// R2 storage ID (for track/album artwork) 111 + pub image_id: Option<String>, 112 + /// Full URL (for external images like avatars) 113 + pub url: Option<String>, 114 + /// Why this image was flagged 115 + pub reason: Option<String>, 116 + /// Admin who flagged it 117 + pub flagged_by: Option<String>, 118 + } 119 + 120 + /// Response after adding a sensitive image. 121 + #[derive(Debug, Serialize)] 122 + pub struct AddSensitiveImageResponse { 123 + pub id: i64, 124 + pub message: String, 125 + } 126 + 127 + /// Request to remove a sensitive image. 128 + #[derive(Debug, Deserialize)] 129 + pub struct RemoveSensitiveImageRequest { 130 + pub id: i64, 131 + } 132 + 133 + /// Response after removing a sensitive image. 134 + #[derive(Debug, Serialize)] 135 + pub struct RemoveSensitiveImageResponse { 136 + pub removed: bool, 137 + pub message: String, 138 + } 139 + 140 + /// Request to create a review batch. 141 + #[derive(Debug, Deserialize)] 142 + pub struct CreateBatchRequest { 143 + /// URIs to include. If empty, uses all pending flags. 144 + #[serde(default)] 145 + pub uris: Vec<String>, 146 + /// Who created this batch. 147 + pub created_by: Option<String>, 148 + } 149 + 150 + /// Response after creating a review batch. 151 + #[derive(Debug, Serialize)] 152 + pub struct CreateBatchResponse { 153 + pub id: String, 154 + pub url: String, 155 + pub flag_count: usize, 156 + } 157 + 158 /// List all flagged tracks - returns JSON for API, HTML for htmx. 159 pub async fn list_flagged( 160 State(state): State<AppState>, ··· 343 Ok(Json(StoreContextResponse { 344 message: format!("context stored for {}", request.uri), 345 })) 346 + } 347 + 348 + /// Create a review batch from pending flags. 349 + pub async fn create_batch( 350 + State(state): State<AppState>, 351 + Json(request): Json<CreateBatchRequest>, 352 + ) -> Result<Json<CreateBatchResponse>, AppError> { 353 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 354 + 355 + // Get URIs to include 356 + let uris = if request.uris.is_empty() { 357 + let pending = db.get_pending_flags().await?; 358 + pending 359 + .into_iter() 360 + .filter(|t| !t.resolved) 361 + .map(|t| t.uri) 362 + .collect() 363 + } else { 364 + request.uris 365 + }; 366 + 367 + if uris.is_empty() { 368 + return Err(AppError::BadRequest("no flags to review".to_string())); 369 + } 370 + 371 + let id = generate_batch_id(); 372 + let flag_count = uris.len(); 373 + 374 + tracing::info!( 375 + batch_id = %id, 376 + flag_count = flag_count, 377 + "creating review batch" 378 + ); 379 + 380 + db.create_batch(&id, &uris, request.created_by.as_deref()) 381 + .await?; 382 + 383 + let url = format!("/admin/review/{}", id); 384 + 385 + Ok(Json(CreateBatchResponse { id, url, flag_count })) 386 + } 387 + 388 + /// Generate a short, URL-safe batch ID. 389 + fn generate_batch_id() -> String { 390 + use std::time::{SystemTime, UNIX_EPOCH}; 391 + let now = SystemTime::now() 392 + .duration_since(UNIX_EPOCH) 393 + .unwrap() 394 + .as_millis(); 395 + let rand_part: u32 = rand::random(); 396 + format!("{:x}{:x}", (now as u64) & 0xFFFFFFFF, rand_part & 0xFFFF) 397 + } 398 + 399 + /// Add a sensitive image entry. 400 + pub async fn add_sensitive_image( 401 + State(state): State<AppState>, 402 + Json(request): Json<AddSensitiveImageRequest>, 403 + ) -> Result<Json<AddSensitiveImageResponse>, AppError> { 404 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 405 + 406 + // Validate: at least one of image_id or url must be provided 407 + if request.image_id.is_none() && request.url.is_none() { 408 + return Err(AppError::BadRequest( 409 + "at least one of image_id or url must be provided".to_string(), 410 + )); 411 + } 412 + 413 + tracing::info!( 414 + image_id = ?request.image_id, 415 + url = ?request.url, 416 + reason = ?request.reason, 417 + flagged_by = ?request.flagged_by, 418 + "adding sensitive image" 419 + ); 420 + 421 + let id = db 422 + .add_sensitive_image( 423 + request.image_id.as_deref(), 424 + request.url.as_deref(), 425 + request.reason.as_deref(), 426 + request.flagged_by.as_deref(), 427 + ) 428 + .await?; 429 + 430 + Ok(Json(AddSensitiveImageResponse { 431 + id, 432 + message: "sensitive image added".to_string(), 433 + })) 434 + } 435 + 436 + /// Remove a sensitive image entry. 437 + pub async fn remove_sensitive_image( 438 + State(state): State<AppState>, 439 + Json(request): Json<RemoveSensitiveImageRequest>, 440 + ) -> Result<Json<RemoveSensitiveImageResponse>, AppError> { 441 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 442 + 443 + tracing::info!(id = request.id, "removing sensitive image"); 444 + 445 + let removed = db.remove_sensitive_image(request.id).await?; 446 + 447 + let message = if removed { 448 + format!("sensitive image {} removed", request.id) 449 + } else { 450 + format!("sensitive image {} not found", request.id) 451 + }; 452 + 453 + Ok(Json(RemoveSensitiveImageResponse { removed, message })) 454 } 455 456 /// Serve the admin UI HTML from static file.
+6 -1
moderation/src/auth.rs
··· 12 let path = req.uri().path(); 13 14 // Public endpoints - no auth required 15 - // Note: /admin serves HTML, auth is handled client-side for API calls 16 // Static files must be public for admin UI CSS/JS to load 17 if path == "/" 18 || path == "/health" 19 || path == "/admin" 20 || path.starts_with("/static/") 21 || path.starts_with("/xrpc/com.atproto.label.") 22 {
··· 12 let path = req.uri().path(); 13 14 // Public endpoints - no auth required 15 + // Note: /admin and /admin/review/:id serve HTML, auth is handled client-side for API calls 16 // Static files must be public for admin UI CSS/JS to load 17 + let is_review_page = path.starts_with("/admin/review/") 18 + && !path.ends_with("/data") 19 + && !path.ends_with("/submit"); 20 if path == "/" 21 || path == "/health" 22 + || path == "/sensitive-images" 23 || path == "/admin" 24 + || is_review_page 25 || path.starts_with("/static/") 26 || path.starts_with("/xrpc/com.atproto.label.") 27 {
+336 -1
moderation/src/db.rs
··· 2 3 use chrono::{DateTime, Utc}; 4 use serde::{Deserialize, Serialize}; 5 - use sqlx::{postgres::PgPoolOptions, PgPool}; 6 7 use crate::admin::FlaggedTrack; 8 use crate::labels::Label; 9 10 /// Type alias for context row from database query. 11 type ContextRow = ( 12 Option<i64>, // track_id ··· 55 FingerprintNoise, 56 /// Legal cover version or remix 57 CoverVersion, 58 /// Other reason (see resolution_notes) 59 Other, 60 } ··· 67 Self::Licensed => "licensed", 68 Self::FingerprintNoise => "fingerprint noise", 69 Self::CoverVersion => "cover/remix", 70 Self::Other => "other", 71 } 72 } ··· 78 "licensed" => Some(Self::Licensed), 79 "fingerprint_noise" => Some(Self::FingerprintNoise), 80 "cover_version" => Some(Self::CoverVersion), 81 "other" => Some(Self::Other), 82 _ => None, 83 } ··· 193 .execute(&self.pool) 194 .await?; 195 sqlx::query("ALTER TABLE label_context ADD COLUMN IF NOT EXISTS resolution_notes TEXT") 196 .execute(&self.pool) 197 .await?; 198 ··· 592 .collect(); 593 594 Ok(tracks) 595 } 596 } 597
··· 2 3 use chrono::{DateTime, Utc}; 4 use serde::{Deserialize, Serialize}; 5 + use sqlx::{postgres::PgPoolOptions, FromRow, PgPool}; 6 7 use crate::admin::FlaggedTrack; 8 use crate::labels::Label; 9 10 + /// Sensitive image record from the database. 11 + #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] 12 + pub struct SensitiveImageRow { 13 + pub id: i64, 14 + /// R2 storage ID (for track/album artwork) 15 + pub image_id: Option<String>, 16 + /// Full URL (for external images like avatars) 17 + pub url: Option<String>, 18 + /// Why this image was flagged 19 + pub reason: Option<String>, 20 + /// When the image was flagged 21 + pub flagged_at: DateTime<Utc>, 22 + /// Admin who flagged it 23 + pub flagged_by: Option<String>, 24 + } 25 + 26 + /// Review batch for mobile-friendly flag review. 27 + #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] 28 + pub struct ReviewBatch { 29 + pub id: String, 30 + pub created_at: DateTime<Utc>, 31 + pub expires_at: Option<DateTime<Utc>>, 32 + /// Status: pending, completed. 33 + pub status: String, 34 + /// Who created this batch. 35 + pub created_by: Option<String>, 36 + } 37 + 38 + /// A flag within a review batch. 39 + #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] 40 + pub struct BatchFlag { 41 + pub id: i64, 42 + pub batch_id: String, 43 + pub uri: String, 44 + pub reviewed: bool, 45 + pub reviewed_at: Option<DateTime<Utc>>, 46 + /// Decision: approved, rejected, or null. 47 + pub decision: Option<String>, 48 + } 49 + 50 /// Type alias for context row from database query. 51 type ContextRow = ( 52 Option<i64>, // track_id ··· 95 FingerprintNoise, 96 /// Legal cover version or remix 97 CoverVersion, 98 + /// Content was deleted from plyr.fm 99 + ContentDeleted, 100 /// Other reason (see resolution_notes) 101 Other, 102 } ··· 109 Self::Licensed => "licensed", 110 Self::FingerprintNoise => "fingerprint noise", 111 Self::CoverVersion => "cover/remix", 112 + Self::ContentDeleted => "content deleted", 113 Self::Other => "other", 114 } 115 } ··· 121 "licensed" => Some(Self::Licensed), 122 "fingerprint_noise" => Some(Self::FingerprintNoise), 123 "cover_version" => Some(Self::CoverVersion), 124 + "content_deleted" => Some(Self::ContentDeleted), 125 "other" => Some(Self::Other), 126 _ => None, 127 } ··· 237 .execute(&self.pool) 238 .await?; 239 sqlx::query("ALTER TABLE label_context ADD COLUMN IF NOT EXISTS resolution_notes TEXT") 240 + .execute(&self.pool) 241 + .await?; 242 + 243 + // Sensitive images table for content moderation 244 + sqlx::query( 245 + r#" 246 + CREATE TABLE IF NOT EXISTS sensitive_images ( 247 + id BIGSERIAL PRIMARY KEY, 248 + image_id TEXT, 249 + url TEXT, 250 + reason TEXT, 251 + flagged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 252 + flagged_by TEXT 253 + ) 254 + "#, 255 + ) 256 + .execute(&self.pool) 257 + .await?; 258 + 259 + sqlx::query("CREATE INDEX IF NOT EXISTS idx_sensitive_images_image_id ON sensitive_images(image_id)") 260 + .execute(&self.pool) 261 + .await?; 262 + sqlx::query("CREATE INDEX IF NOT EXISTS idx_sensitive_images_url ON sensitive_images(url)") 263 + .execute(&self.pool) 264 + .await?; 265 + 266 + // Review batches for mobile-friendly flag review 267 + sqlx::query( 268 + r#" 269 + CREATE TABLE IF NOT EXISTS review_batches ( 270 + id TEXT PRIMARY KEY, 271 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 272 + expires_at TIMESTAMPTZ, 273 + status TEXT NOT NULL DEFAULT 'pending', 274 + created_by TEXT 275 + ) 276 + "#, 277 + ) 278 + .execute(&self.pool) 279 + .await?; 280 + 281 + // Flags within review batches 282 + sqlx::query( 283 + r#" 284 + CREATE TABLE IF NOT EXISTS batch_flags ( 285 + id BIGSERIAL PRIMARY KEY, 286 + batch_id TEXT NOT NULL REFERENCES review_batches(id) ON DELETE CASCADE, 287 + uri TEXT NOT NULL, 288 + reviewed BOOLEAN NOT NULL DEFAULT FALSE, 289 + reviewed_at TIMESTAMPTZ, 290 + decision TEXT, 291 + UNIQUE(batch_id, uri) 292 + ) 293 + "#, 294 + ) 295 + .execute(&self.pool) 296 + .await?; 297 + 298 + sqlx::query("CREATE INDEX IF NOT EXISTS idx_batch_flags_batch_id ON batch_flags(batch_id)") 299 .execute(&self.pool) 300 .await?; 301 ··· 695 .collect(); 696 697 Ok(tracks) 698 + } 699 + 700 + // ------------------------------------------------------------------------- 701 + // Review batches 702 + // ------------------------------------------------------------------------- 703 + 704 + /// Create a review batch with the given flags. 705 + pub async fn create_batch( 706 + &self, 707 + id: &str, 708 + uris: &[String], 709 + created_by: Option<&str>, 710 + ) -> Result<ReviewBatch, sqlx::Error> { 711 + let batch = sqlx::query_as::<_, ReviewBatch>( 712 + r#" 713 + INSERT INTO review_batches (id, created_by) 714 + VALUES ($1, $2) 715 + RETURNING id, created_at, expires_at, status, created_by 716 + "#, 717 + ) 718 + .bind(id) 719 + .bind(created_by) 720 + .fetch_one(&self.pool) 721 + .await?; 722 + 723 + for uri in uris { 724 + sqlx::query( 725 + r#" 726 + INSERT INTO batch_flags (batch_id, uri) 727 + VALUES ($1, $2) 728 + ON CONFLICT (batch_id, uri) DO NOTHING 729 + "#, 730 + ) 731 + .bind(id) 732 + .bind(uri) 733 + .execute(&self.pool) 734 + .await?; 735 + } 736 + 737 + Ok(batch) 738 + } 739 + 740 + /// Get a batch by ID. 741 + pub async fn get_batch(&self, id: &str) -> Result<Option<ReviewBatch>, sqlx::Error> { 742 + sqlx::query_as::<_, ReviewBatch>( 743 + r#" 744 + SELECT id, created_at, expires_at, status, created_by 745 + FROM review_batches 746 + WHERE id = $1 747 + "#, 748 + ) 749 + .bind(id) 750 + .fetch_optional(&self.pool) 751 + .await 752 + } 753 + 754 + /// Get all flags in a batch with their context. 755 + pub async fn get_batch_flags(&self, batch_id: &str) -> Result<Vec<FlaggedTrack>, sqlx::Error> { 756 + let rows: Vec<FlaggedRow> = sqlx::query_as( 757 + r#" 758 + SELECT l.seq, l.uri, l.val, l.cts, 759 + c.track_id, c.track_title, c.artist_handle, c.artist_did, c.highest_score, c.matches, 760 + c.resolution_reason, c.resolution_notes 761 + FROM batch_flags bf 762 + JOIN labels l ON l.uri = bf.uri AND l.val = 'copyright-violation' AND l.neg = false 763 + LEFT JOIN label_context c ON l.uri = c.uri 764 + WHERE bf.batch_id = $1 765 + ORDER BY l.seq DESC 766 + "#, 767 + ) 768 + .bind(batch_id) 769 + .fetch_all(&self.pool) 770 + .await?; 771 + 772 + let batch_uris: Vec<String> = rows.iter().map(|r| r.1.clone()).collect(); 773 + let negated_uris: std::collections::HashSet<String> = if !batch_uris.is_empty() { 774 + sqlx::query_scalar::<_, String>( 775 + r#" 776 + SELECT DISTINCT uri 777 + FROM labels 778 + WHERE val = 'copyright-violation' AND neg = true AND uri = ANY($1) 779 + "#, 780 + ) 781 + .bind(&batch_uris) 782 + .fetch_all(&self.pool) 783 + .await? 784 + .into_iter() 785 + .collect() 786 + } else { 787 + std::collections::HashSet::new() 788 + }; 789 + 790 + let tracks = rows 791 + .into_iter() 792 + .map( 793 + |( 794 + seq, 795 + uri, 796 + val, 797 + cts, 798 + track_id, 799 + track_title, 800 + artist_handle, 801 + artist_did, 802 + highest_score, 803 + matches, 804 + resolution_reason, 805 + resolution_notes, 806 + )| { 807 + let context = if track_id.is_some() 808 + || track_title.is_some() 809 + || artist_handle.is_some() 810 + || resolution_reason.is_some() 811 + { 812 + Some(LabelContext { 813 + track_id, 814 + track_title, 815 + artist_handle, 816 + artist_did, 817 + highest_score, 818 + matches: matches.and_then(|v| serde_json::from_value(v).ok()), 819 + resolution_reason: resolution_reason 820 + .and_then(|s| ResolutionReason::from_str(&s)), 821 + resolution_notes, 822 + }) 823 + } else { 824 + None 825 + }; 826 + 827 + FlaggedTrack { 828 + seq, 829 + uri: uri.clone(), 830 + val, 831 + created_at: cts.format("%Y-%m-%d %H:%M:%S").to_string(), 832 + resolved: negated_uris.contains(&uri), 833 + context, 834 + } 835 + }, 836 + ) 837 + .collect(); 838 + 839 + Ok(tracks) 840 + } 841 + 842 + /// Update batch status. 843 + pub async fn update_batch_status(&self, id: &str, status: &str) -> Result<bool, sqlx::Error> { 844 + let result = sqlx::query("UPDATE review_batches SET status = $1 WHERE id = $2") 845 + .bind(status) 846 + .bind(id) 847 + .execute(&self.pool) 848 + .await?; 849 + Ok(result.rows_affected() > 0) 850 + } 851 + 852 + /// Mark a flag in a batch as reviewed. 853 + pub async fn mark_flag_reviewed( 854 + &self, 855 + batch_id: &str, 856 + uri: &str, 857 + decision: &str, 858 + ) -> Result<bool, sqlx::Error> { 859 + let result = sqlx::query( 860 + r#" 861 + UPDATE batch_flags 862 + SET reviewed = true, reviewed_at = NOW(), decision = $1 863 + WHERE batch_id = $2 AND uri = $3 864 + "#, 865 + ) 866 + .bind(decision) 867 + .bind(batch_id) 868 + .bind(uri) 869 + .execute(&self.pool) 870 + .await?; 871 + Ok(result.rows_affected() > 0) 872 + } 873 + 874 + /// Get pending (non-reviewed) flags from a batch. 875 + pub async fn get_batch_pending_uris(&self, batch_id: &str) -> Result<Vec<String>, sqlx::Error> { 876 + sqlx::query_scalar::<_, String>( 877 + r#" 878 + SELECT uri FROM batch_flags 879 + WHERE batch_id = $1 AND reviewed = false 880 + "#, 881 + ) 882 + .bind(batch_id) 883 + .fetch_all(&self.pool) 884 + .await 885 + } 886 + 887 + // ------------------------------------------------------------------------- 888 + // Sensitive images 889 + // ------------------------------------------------------------------------- 890 + 891 + /// Get all sensitive images. 892 + pub async fn get_sensitive_images(&self) -> Result<Vec<SensitiveImageRow>, sqlx::Error> { 893 + sqlx::query_as::<_, SensitiveImageRow>( 894 + "SELECT id, image_id, url, reason, flagged_at, flagged_by FROM sensitive_images ORDER BY flagged_at DESC", 895 + ) 896 + .fetch_all(&self.pool) 897 + .await 898 + } 899 + 900 + /// Add a sensitive image entry. 901 + pub async fn add_sensitive_image( 902 + &self, 903 + image_id: Option<&str>, 904 + url: Option<&str>, 905 + reason: Option<&str>, 906 + flagged_by: Option<&str>, 907 + ) -> Result<i64, sqlx::Error> { 908 + sqlx::query_scalar::<_, i64>( 909 + r#" 910 + INSERT INTO sensitive_images (image_id, url, reason, flagged_by) 911 + VALUES ($1, $2, $3, $4) 912 + RETURNING id 913 + "#, 914 + ) 915 + .bind(image_id) 916 + .bind(url) 917 + .bind(reason) 918 + .bind(flagged_by) 919 + .fetch_one(&self.pool) 920 + .await 921 + } 922 + 923 + /// Remove a sensitive image entry by ID. 924 + pub async fn remove_sensitive_image(&self, id: i64) -> Result<bool, sqlx::Error> { 925 + let result = sqlx::query("DELETE FROM sensitive_images WHERE id = $1") 926 + .bind(id) 927 + .execute(&self.pool) 928 + .await?; 929 + Ok(result.rows_affected() > 0) 930 } 931 } 932
+26
moderation/src/handlers.rs
··· 63 pub label: Label, 64 } 65 66 // --- handlers --- 67 68 /// Health check endpoint. ··· 206 } 207 208 Ok(Json(EmitLabelResponse { seq, label })) 209 } 210 211 #[cfg(test)]
··· 63 pub label: Label, 64 } 65 66 + /// Response for sensitive images endpoint. 67 + #[derive(Debug, Serialize)] 68 + pub struct SensitiveImagesResponse { 69 + /// R2 image IDs (for track/album artwork) 70 + pub image_ids: Vec<String>, 71 + /// Full URLs (for external images like avatars) 72 + pub urls: Vec<String>, 73 + } 74 + 75 // --- handlers --- 76 77 /// Health check endpoint. ··· 215 } 216 217 Ok(Json(EmitLabelResponse { seq, label })) 218 + } 219 + 220 + /// Get all sensitive images (public endpoint). 221 + /// 222 + /// Returns image_ids (R2 storage IDs) and urls (full URLs) for all flagged images. 223 + /// Clients should check both lists when determining if an image is sensitive. 224 + pub async fn get_sensitive_images( 225 + State(state): State<AppState>, 226 + ) -> Result<Json<SensitiveImagesResponse>, AppError> { 227 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 228 + 229 + let images = db.get_sensitive_images().await?; 230 + 231 + let image_ids: Vec<String> = images.iter().filter_map(|i| i.image_id.clone()).collect(); 232 + let urls: Vec<String> = images.iter().filter_map(|i| i.url.clone()).collect(); 233 + 234 + Ok(Json(SensitiveImagesResponse { image_ids, urls })) 235 } 236 237 #[cfg(test)]
+13
moderation/src/main.rs
··· 25 mod db; 26 mod handlers; 27 mod labels; 28 mod state; 29 mod xrpc; 30 ··· 72 .route("/", get(handlers::landing)) 73 // Health check 74 .route("/health", get(handlers::health)) 75 // AuDD scanning 76 .route("/scan", post(audd::scan)) 77 // Label emission (internal API) ··· 84 .route("/admin/resolve-htmx", post(admin::resolve_flag_htmx)) 85 .route("/admin/context", post(admin::store_context)) 86 .route("/admin/active-labels", post(admin::get_active_labels)) 87 // Static files (CSS, JS for admin UI) 88 .nest_service("/static", ServeDir::new("static")) 89 // ATProto XRPC endpoints (public)
··· 25 mod db; 26 mod handlers; 27 mod labels; 28 + mod review; 29 mod state; 30 mod xrpc; 31 ··· 73 .route("/", get(handlers::landing)) 74 // Health check 75 .route("/health", get(handlers::health)) 76 + // Sensitive images (public) 77 + .route("/sensitive-images", get(handlers::get_sensitive_images)) 78 // AuDD scanning 79 .route("/scan", post(audd::scan)) 80 // Label emission (internal API) ··· 87 .route("/admin/resolve-htmx", post(admin::resolve_flag_htmx)) 88 .route("/admin/context", post(admin::store_context)) 89 .route("/admin/active-labels", post(admin::get_active_labels)) 90 + .route("/admin/sensitive-images", post(admin::add_sensitive_image)) 91 + .route( 92 + "/admin/sensitive-images/remove", 93 + post(admin::remove_sensitive_image), 94 + ) 95 + .route("/admin/batches", post(admin::create_batch)) 96 + // Review endpoints (under admin, auth protected) 97 + .route("/admin/review/:id", get(review::review_page)) 98 + .route("/admin/review/:id/data", get(review::review_data)) 99 + .route("/admin/review/:id/submit", post(review::submit_review)) 100 // Static files (CSS, JS for admin UI) 101 .nest_service("/static", ServeDir::new("static")) 102 // ATProto XRPC endpoints (public)
+526
moderation/src/review.rs
···
··· 1 + //! Review endpoints for batch flag review. 2 + //! 3 + //! These endpoints are behind the same auth as admin endpoints. 4 + 5 + use axum::{ 6 + extract::{Path, State}, 7 + http::header::CONTENT_TYPE, 8 + response::{IntoResponse, Response}, 9 + Json, 10 + }; 11 + use serde::{Deserialize, Serialize}; 12 + 13 + use crate::admin::FlaggedTrack; 14 + use crate::state::{AppError, AppState}; 15 + 16 + /// Response for review page data. 17 + #[derive(Debug, Serialize)] 18 + pub struct ReviewPageData { 19 + pub batch_id: String, 20 + pub flags: Vec<FlaggedTrack>, 21 + pub status: String, 22 + } 23 + 24 + /// Request to submit review decisions. 25 + #[derive(Debug, Deserialize)] 26 + pub struct SubmitReviewRequest { 27 + pub decisions: Vec<ReviewDecision>, 28 + } 29 + 30 + /// A single review decision. 31 + #[derive(Debug, Deserialize)] 32 + pub struct ReviewDecision { 33 + pub uri: String, 34 + /// "clear" (false positive), "defer" (acknowledge, no action), "confirm" (real violation) 35 + pub decision: String, 36 + } 37 + 38 + /// Response after submitting review. 39 + #[derive(Debug, Serialize)] 40 + pub struct SubmitReviewResponse { 41 + pub resolved_count: usize, 42 + pub message: String, 43 + } 44 + 45 + /// Get review page HTML. 46 + pub async fn review_page( 47 + State(state): State<AppState>, 48 + Path(batch_id): Path<String>, 49 + ) -> Result<Response, AppError> { 50 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 51 + 52 + let batch = db 53 + .get_batch(&batch_id) 54 + .await? 55 + .ok_or(AppError::NotFound("batch not found".to_string()))?; 56 + 57 + let flags = db.get_batch_flags(&batch_id).await?; 58 + let html = render_review_page(&batch_id, &flags, &batch.status); 59 + 60 + Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()) 61 + } 62 + 63 + /// Get review data as JSON. 64 + pub async fn review_data( 65 + State(state): State<AppState>, 66 + Path(batch_id): Path<String>, 67 + ) -> Result<Json<ReviewPageData>, AppError> { 68 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 69 + 70 + let batch = db 71 + .get_batch(&batch_id) 72 + .await? 73 + .ok_or(AppError::NotFound("batch not found".to_string()))?; 74 + 75 + let flags = db.get_batch_flags(&batch_id).await?; 76 + 77 + Ok(Json(ReviewPageData { 78 + batch_id, 79 + flags, 80 + status: batch.status, 81 + })) 82 + } 83 + 84 + /// Submit review decisions. 85 + pub async fn submit_review( 86 + State(state): State<AppState>, 87 + Path(batch_id): Path<String>, 88 + Json(request): Json<SubmitReviewRequest>, 89 + ) -> Result<Json<SubmitReviewResponse>, AppError> { 90 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 91 + let signer = state 92 + .signer 93 + .as_ref() 94 + .ok_or(AppError::LabelerNotConfigured)?; 95 + 96 + let _batch = db 97 + .get_batch(&batch_id) 98 + .await? 99 + .ok_or(AppError::NotFound("batch not found".to_string()))?; 100 + 101 + let mut resolved_count = 0; 102 + 103 + for decision in &request.decisions { 104 + tracing::info!( 105 + batch_id = %batch_id, 106 + uri = %decision.uri, 107 + decision = %decision.decision, 108 + "processing review decision" 109 + ); 110 + 111 + db.mark_flag_reviewed(&batch_id, &decision.uri, &decision.decision) 112 + .await?; 113 + 114 + match decision.decision.as_str() { 115 + "clear" => { 116 + // False positive - emit negation label to clear the flag 117 + let label = 118 + crate::labels::Label::new(signer.did(), &decision.uri, "copyright-violation") 119 + .negated(); 120 + let label = signer.sign_label(label)?; 121 + let seq = db.store_label(&label).await?; 122 + 123 + db.store_resolution( 124 + &decision.uri, 125 + crate::db::ResolutionReason::FingerprintNoise, 126 + Some("batch review: cleared"), 127 + ) 128 + .await?; 129 + 130 + if let Some(tx) = &state.label_tx { 131 + let _ = tx.send((seq, label)); 132 + } 133 + 134 + resolved_count += 1; 135 + } 136 + "defer" => { 137 + // Acknowledge but take no action - flag stays active 138 + // Just mark as reviewed in the batch, no label changes 139 + tracing::info!(uri = %decision.uri, "deferred - no action taken"); 140 + } 141 + "confirm" => { 142 + // Real violation - flag stays active, could add enforcement later 143 + tracing::info!(uri = %decision.uri, "confirmed as violation"); 144 + } 145 + _ => { 146 + tracing::warn!(uri = %decision.uri, decision = %decision.decision, "unknown decision type"); 147 + } 148 + } 149 + } 150 + 151 + let pending = db.get_batch_pending_uris(&batch_id).await?; 152 + if pending.is_empty() { 153 + db.update_batch_status(&batch_id, "completed").await?; 154 + } 155 + 156 + Ok(Json(SubmitReviewResponse { 157 + resolved_count, 158 + message: format!( 159 + "processed {} decisions, resolved {} flags", 160 + request.decisions.len(), 161 + resolved_count 162 + ), 163 + })) 164 + } 165 + 166 + /// Render the review page. 167 + fn render_review_page(batch_id: &str, flags: &[FlaggedTrack], status: &str) -> String { 168 + let pending: Vec<_> = flags.iter().filter(|f| !f.resolved).collect(); 169 + let resolved: Vec<_> = flags.iter().filter(|f| f.resolved).collect(); 170 + 171 + let pending_cards: Vec<String> = pending.iter().map(|f| render_review_card(f)).collect(); 172 + let resolved_cards: Vec<String> = resolved.iter().map(|f| render_review_card(f)).collect(); 173 + 174 + let pending_html = if pending_cards.is_empty() { 175 + "<div class=\"empty\">all flags reviewed!</div>".to_string() 176 + } else { 177 + pending_cards.join("\n") 178 + }; 179 + 180 + let resolved_html = if resolved_cards.is_empty() { 181 + String::new() 182 + } else { 183 + format!( 184 + r#"<details class="resolved-section"> 185 + <summary>{} resolved</summary> 186 + {} 187 + </details>"#, 188 + resolved_cards.len(), 189 + resolved_cards.join("\n") 190 + ) 191 + }; 192 + 193 + let status_badge = if status == "completed" { 194 + r#"<span class="badge resolved">completed</span>"# 195 + } else { 196 + "" 197 + }; 198 + 199 + format!( 200 + r#"<!DOCTYPE html> 201 + <html lang="en"> 202 + <head> 203 + <meta charset="utf-8"> 204 + <meta name="viewport" content="width=device-width, initial-scale=1"> 205 + <title>review batch - plyr.fm</title> 206 + <link rel="stylesheet" href="/static/admin.css"> 207 + <style>{}</style> 208 + </head> 209 + <body> 210 + <h1>plyr.fm moderation</h1> 211 + <p class="subtitle"> 212 + <a href="/admin">← back to dashboard</a> 213 + <span style="margin: 0 12px; color: var(--text-muted);">|</span> 214 + batch review: {} pending {} 215 + </p> 216 + 217 + <div class="auth-section" id="auth-section"> 218 + <input type="password" id="auth-token" placeholder="auth token" 219 + onkeyup="if(event.key==='Enter')authenticate()"> 220 + <button class="btn btn-primary" onclick="authenticate()">authenticate</button> 221 + </div> 222 + 223 + <form id="review-form" style="display: none;"> 224 + <div class="flags-list"> 225 + {} 226 + </div> 227 + 228 + {} 229 + 230 + <div class="submit-bar"> 231 + <button type="submit" class="btn btn-primary" id="submit-btn" disabled> 232 + submit decisions 233 + </button> 234 + </div> 235 + </form> 236 + 237 + <script> 238 + const form = document.getElementById('review-form'); 239 + const submitBtn = document.getElementById('submit-btn'); 240 + const authSection = document.getElementById('auth-section'); 241 + const batchId = '{}'; 242 + 243 + let currentToken = ''; 244 + const decisions = {{}}; 245 + 246 + function authenticate() {{ 247 + const token = document.getElementById('auth-token').value; 248 + if (token && token !== '••••••••') {{ 249 + localStorage.setItem('mod_token', token); 250 + currentToken = token; 251 + showReviewForm(); 252 + }} 253 + }} 254 + 255 + function showReviewForm() {{ 256 + authSection.style.display = 'none'; 257 + form.style.display = 'block'; 258 + }} 259 + 260 + // Check for saved token on load 261 + const savedToken = localStorage.getItem('mod_token'); 262 + if (savedToken) {{ 263 + currentToken = savedToken; 264 + document.getElementById('auth-token').value = '••••••••'; 265 + showReviewForm(); 266 + }} 267 + 268 + function updateSubmitBtn() {{ 269 + const count = Object.keys(decisions).length; 270 + submitBtn.disabled = count === 0; 271 + submitBtn.textContent = count > 0 ? `submit ${{count}} decision${{count > 1 ? 's' : ''}}` : 'submit decisions'; 272 + }} 273 + 274 + function setDecision(uri, decision) {{ 275 + // Toggle off if clicking the same decision 276 + if (decisions[uri] === decision) {{ 277 + delete decisions[uri]; 278 + const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`); 279 + if (card) card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm'); 280 + }} else {{ 281 + decisions[uri] = decision; 282 + const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`); 283 + if (card) {{ 284 + card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm'); 285 + card.classList.add('decision-' + decision); 286 + }} 287 + }} 288 + updateSubmitBtn(); 289 + }} 290 + 291 + form.addEventListener('submit', async (e) => {{ 292 + e.preventDefault(); 293 + submitBtn.disabled = true; 294 + submitBtn.textContent = 'submitting...'; 295 + 296 + try {{ 297 + const response = await fetch(`/admin/review/${{batchId}}/submit`, {{ 298 + method: 'POST', 299 + headers: {{ 300 + 'Content-Type': 'application/json', 301 + 'X-Moderation-Key': currentToken 302 + }}, 303 + body: JSON.stringify({{ 304 + decisions: Object.entries(decisions).map(([uri, decision]) => ({{ uri, decision }})) 305 + }}) 306 + }}); 307 + 308 + if (response.status === 401) {{ 309 + localStorage.removeItem('mod_token'); 310 + currentToken = ''; 311 + authSection.style.display = 'block'; 312 + form.style.display = 'none'; 313 + document.getElementById('auth-token').value = ''; 314 + alert('invalid token'); 315 + return; 316 + }} 317 + 318 + if (response.ok) {{ 319 + const result = await response.json(); 320 + alert(result.message); 321 + location.reload(); 322 + }} else {{ 323 + const err = await response.json(); 324 + alert('error: ' + (err.message || 'unknown error')); 325 + submitBtn.disabled = false; 326 + updateSubmitBtn(); 327 + }} 328 + }} catch (err) {{ 329 + alert('network error: ' + err.message); 330 + submitBtn.disabled = false; 331 + updateSubmitBtn(); 332 + }} 333 + }}); 334 + </script> 335 + </body> 336 + </html>"#, 337 + REVIEW_CSS, 338 + pending.len(), 339 + status_badge, 340 + pending_html, 341 + resolved_html, 342 + html_escape(batch_id) 343 + ) 344 + } 345 + 346 + /// Render a single review card. 347 + fn render_review_card(track: &FlaggedTrack) -> String { 348 + let ctx = track.context.as_ref(); 349 + 350 + let title = ctx 351 + .and_then(|c| c.track_title.as_deref()) 352 + .unwrap_or("unknown track"); 353 + let artist = ctx 354 + .and_then(|c| c.artist_handle.as_deref()) 355 + .unwrap_or("unknown"); 356 + let track_id = ctx.and_then(|c| c.track_id); 357 + 358 + let title_html = if let Some(id) = track_id { 359 + format!( 360 + r#"<a href="https://plyr.fm/track/{}" target="_blank">{}</a>"#, 361 + id, 362 + html_escape(title) 363 + ) 364 + } else { 365 + html_escape(title) 366 + }; 367 + 368 + let matches_html = ctx 369 + .and_then(|c| c.matches.as_ref()) 370 + .filter(|m| !m.is_empty()) 371 + .map(|matches| { 372 + let items: Vec<String> = matches 373 + .iter() 374 + .take(3) 375 + .map(|m| { 376 + format!( 377 + r#"<div class="match-item"><span class="title">{}</span> <span class="artist">by {}</span></div>"#, 378 + html_escape(&m.title), 379 + html_escape(&m.artist) 380 + ) 381 + }) 382 + .collect(); 383 + format!( 384 + r#"<div class="matches"><h4>potential matches</h4>{}</div>"#, 385 + items.join("\n") 386 + ) 387 + }) 388 + .unwrap_or_default(); 389 + 390 + let resolved_badge = if track.resolved { 391 + r#"<span class="badge resolved">resolved</span>"# 392 + } else { 393 + r#"<span class="badge pending">pending</span>"# 394 + }; 395 + 396 + let action_buttons = if !track.resolved { 397 + format!( 398 + r#"<div class="flag-actions"> 399 + <button type="button" class="btn btn-clear" onclick="setDecision('{}', 'clear')">clear</button> 400 + <button type="button" class="btn btn-defer" onclick="setDecision('{}', 'defer')">defer</button> 401 + <button type="button" class="btn btn-confirm" onclick="setDecision('{}', 'confirm')">confirm</button> 402 + </div>"#, 403 + html_escape(&track.uri), 404 + html_escape(&track.uri), 405 + html_escape(&track.uri) 406 + ) 407 + } else { 408 + String::new() 409 + }; 410 + 411 + format!( 412 + r#"<div class="flag-card{}" data-uri="{}"> 413 + <div class="flag-header"> 414 + <div class="track-info"> 415 + <h3>{}</h3> 416 + <div class="artist">@{}</div> 417 + </div> 418 + <div class="flag-badges"> 419 + {} 420 + </div> 421 + </div> 422 + {} 423 + {} 424 + </div>"#, 425 + if track.resolved { " resolved" } else { "" }, 426 + html_escape(&track.uri), 427 + title_html, 428 + html_escape(artist), 429 + resolved_badge, 430 + matches_html, 431 + action_buttons 432 + ) 433 + } 434 + 435 + fn html_escape(s: &str) -> String { 436 + s.replace('&', "&amp;") 437 + .replace('<', "&lt;") 438 + .replace('>', "&gt;") 439 + .replace('"', "&quot;") 440 + .replace('\'', "&#039;") 441 + } 442 + 443 + /// Additional CSS for review page (supplements admin.css) 444 + const REVIEW_CSS: &str = r#" 445 + /* review page specific styles */ 446 + body { padding-bottom: 80px; } 447 + 448 + .subtitle a { 449 + color: var(--accent); 450 + text-decoration: none; 451 + } 452 + .subtitle a:hover { text-decoration: underline; } 453 + 454 + /* action buttons */ 455 + .btn-clear { 456 + background: rgba(74, 222, 128, 0.15); 457 + color: var(--success); 458 + border: 1px solid rgba(74, 222, 128, 0.3); 459 + } 460 + .btn-clear:hover { 461 + background: rgba(74, 222, 128, 0.25); 462 + } 463 + 464 + .btn-defer { 465 + background: rgba(251, 191, 36, 0.15); 466 + color: var(--warning); 467 + border: 1px solid rgba(251, 191, 36, 0.3); 468 + } 469 + .btn-defer:hover { 470 + background: rgba(251, 191, 36, 0.25); 471 + } 472 + 473 + .btn-confirm { 474 + background: rgba(239, 68, 68, 0.15); 475 + color: var(--error); 476 + border: 1px solid rgba(239, 68, 68, 0.3); 477 + } 478 + .btn-confirm:hover { 479 + background: rgba(239, 68, 68, 0.25); 480 + } 481 + 482 + /* card selection states */ 483 + .flag-card.decision-clear { 484 + border-color: var(--success); 485 + background: rgba(74, 222, 128, 0.05); 486 + } 487 + .flag-card.decision-defer { 488 + border-color: var(--warning); 489 + background: rgba(251, 191, 36, 0.05); 490 + } 491 + .flag-card.decision-confirm { 492 + border-color: var(--error); 493 + background: rgba(239, 68, 68, 0.05); 494 + } 495 + 496 + /* submit bar */ 497 + .submit-bar { 498 + position: fixed; 499 + bottom: 0; 500 + left: 0; 501 + right: 0; 502 + padding: 16px 24px; 503 + background: var(--bg-secondary); 504 + border-top: 1px solid var(--border-subtle); 505 + } 506 + .submit-bar .btn { 507 + width: 100%; 508 + max-width: 900px; 509 + margin: 0 auto; 510 + display: block; 511 + padding: 14px; 512 + } 513 + 514 + /* resolved section */ 515 + .resolved-section { 516 + margin-top: 24px; 517 + padding-top: 16px; 518 + border-top: 1px solid var(--border-subtle); 519 + } 520 + .resolved-section summary { 521 + cursor: pointer; 522 + color: var(--text-tertiary); 523 + font-size: 0.85rem; 524 + margin-bottom: 12px; 525 + } 526 + "#;
+8
moderation/src/state.rs
··· 32 #[error("labeler not configured")] 33 LabelerNotConfigured, 34 35 #[error("label error: {0}")] 36 Label(#[from] LabelError), 37 ··· 50 AppError::LabelerNotConfigured => { 51 (StatusCode::SERVICE_UNAVAILABLE, "LabelerNotConfigured") 52 } 53 AppError::Label(_) => (StatusCode::INTERNAL_SERVER_ERROR, "LabelError"), 54 AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError"), 55 AppError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"),
··· 32 #[error("labeler not configured")] 33 LabelerNotConfigured, 34 35 + #[error("bad request: {0}")] 36 + BadRequest(String), 37 + 38 + #[error("not found: {0}")] 39 + NotFound(String), 40 + 41 #[error("label error: {0}")] 42 Label(#[from] LabelError), 43 ··· 56 AppError::LabelerNotConfigured => { 57 (StatusCode::SERVICE_UNAVAILABLE, "LabelerNotConfigured") 58 } 59 + AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "BadRequest"), 60 + AppError::NotFound(_) => (StatusCode::NOT_FOUND, "NotFound"), 61 AppError::Label(_) => (StatusCode::INTERNAL_SERVER_ERROR, "LabelError"), 62 AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError"), 63 AppError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"),
+41
redis/README.md
···
··· 1 + # plyr-redis 2 + 3 + self-hosted Redis on Fly.io for docket background tasks. 4 + 5 + ## deployment 6 + 7 + ```bash 8 + # first time: create app and volume 9 + fly apps create plyr-redis 10 + fly volumes create redis_data --region iad --size 1 -a plyr-redis 11 + 12 + # deploy 13 + fly deploy -a plyr-redis 14 + ``` 15 + 16 + ## connecting from other fly apps 17 + 18 + Redis is accessible via Fly's private network: 19 + 20 + ``` 21 + redis://plyr-redis.internal:6379 22 + ``` 23 + 24 + Update `DOCKET_URL` secret on backend apps: 25 + 26 + ```bash 27 + fly secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api 28 + fly secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api-staging 29 + ``` 30 + 31 + ## configuration 32 + 33 + - **persistence**: AOF (append-only file) enabled for durability 34 + - **memory**: 200MB max with LRU eviction 35 + - **storage**: 1GB volume mounted at /data 36 + 37 + ## cost 38 + 39 + ~$1.94/month (256MB shared-cpu VM) + $0.15/month (1GB volume) = ~$2.09/month 40 + 41 + vs. Upstash pay-as-you-go which was costing ~$75/month at 37M commands.
+29
redis/fly.staging.toml
···
··· 1 + app = "plyr-redis-stg" 2 + primary_region = "iad" 3 + 4 + [build] 5 + image = "redis:7-alpine" 6 + 7 + [mounts] 8 + source = "redis_data" 9 + destination = "/data" 10 + 11 + [env] 12 + # redis config via command line args in [processes] 13 + 14 + [processes] 15 + app = "--appendonly yes --maxmemory 200mb --maxmemory-policy allkeys-lru" 16 + 17 + [[services]] 18 + protocol = "tcp" 19 + internal_port = 6379 20 + processes = ["app"] 21 + 22 + # only accessible within private network 23 + [[services.ports]] 24 + port = 6379 25 + 26 + [[vm]] 27 + memory = "256mb" 28 + cpu_kind = "shared" 29 + cpus = 1
+29
redis/fly.toml
···
··· 1 + app = "plyr-redis" 2 + primary_region = "iad" 3 + 4 + [build] 5 + image = "redis:7-alpine" 6 + 7 + [mounts] 8 + source = "redis_data" 9 + destination = "/data" 10 + 11 + [env] 12 + # redis config via command line args in [processes] 13 + 14 + [processes] 15 + app = "--appendonly yes --maxmemory 200mb --maxmemory-policy allkeys-lru" 16 + 17 + [[services]] 18 + protocol = "tcp" 19 + internal_port = 6379 20 + processes = ["app"] 21 + 22 + # only accessible within private network 23 + [[services.ports]] 24 + port = 6379 25 + 26 + [[vm]] 27 + memory = "256mb" 28 + cpu_kind = "shared" 29 + cpus = 1
+7 -3
scripts/costs/export_costs.py
··· 35 AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests 36 AUDD_BASE_COST = 5.00 # $5/month base 37 38 - # fixed monthly costs (updated 2025-12-16) 39 # fly.io: manually updated from cost explorer (TODO: use fly billing API) 40 # neon: fixed $5/month 41 # cloudflare: mostly free tier 42 FIXED_COSTS = { 43 "fly_io": { 44 "breakdown": { ··· 116 import asyncpg 117 118 billing_start = get_billing_period_start() 119 120 conn = await asyncpg.connect(db_url) 121 try: 122 # get totals: scans, flagged, and derived API requests from duration 123 row = await conn.fetchrow( 124 """ 125 SELECT ··· 139 total_requests = row["total_requests"] 140 total_seconds = row["total_seconds"] 141 142 - # daily breakdown for chart - now includes requests derived from duration 143 daily = await conn.fetch( 144 """ 145 SELECT ··· 153 GROUP BY DATE(cs.scanned_at) 154 ORDER BY date 155 """, 156 - billing_start, 157 AUDD_SECONDS_PER_REQUEST, 158 ) 159
··· 35 AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests 36 AUDD_BASE_COST = 5.00 # $5/month base 37 38 + # fixed monthly costs (updated 2025-12-26) 39 # fly.io: manually updated from cost explorer (TODO: use fly billing API) 40 # neon: fixed $5/month 41 # cloudflare: mostly free tier 42 + # redis: self-hosted on fly (included in fly_io costs) 43 FIXED_COSTS = { 44 "fly_io": { 45 "breakdown": { ··· 117 import asyncpg 118 119 billing_start = get_billing_period_start() 120 + # 30 days of history for the daily chart (independent of billing cycle) 121 + history_start = datetime.now() - timedelta(days=30) 122 123 conn = await asyncpg.connect(db_url) 124 try: 125 # get totals: scans, flagged, and derived API requests from duration 126 + # uses billing period for accurate cost calculation 127 row = await conn.fetchrow( 128 """ 129 SELECT ··· 143 total_requests = row["total_requests"] 144 total_seconds = row["total_seconds"] 145 146 + # daily breakdown for chart - 30 days of history for flexible views 147 daily = await conn.fetch( 148 """ 149 SELECT ··· 157 GROUP BY DATE(cs.scanned_at) 158 ORDER BY date 159 """, 160 + history_start, 161 AUDD_SECONDS_PER_REQUEST, 162 ) 163
+4 -6
scripts/docket_runs.py
··· 38 url = os.environ.get("DOCKET_URL_STAGING") 39 if not url: 40 print("error: DOCKET_URL_STAGING not set") 41 - print( 42 - "hint: export DOCKET_URL_STAGING=rediss://default:xxx@xxx.upstash.io:6379" 43 - ) 44 return 1 45 elif args.env == "production": 46 url = os.environ.get("DOCKET_URL_PRODUCTION") 47 if not url: 48 print("error: DOCKET_URL_PRODUCTION not set") 49 - print( 50 - "hint: export DOCKET_URL_PRODUCTION=rediss://default:xxx@xxx.upstash.io:6379" 51 - ) 52 return 1 53 54 print(f"connecting to {args.env}...")
··· 38 url = os.environ.get("DOCKET_URL_STAGING") 39 if not url: 40 print("error: DOCKET_URL_STAGING not set") 41 + print("hint: flyctl proxy 6380:6379 -a plyr-redis-stg") 42 + print(" export DOCKET_URL_STAGING=redis://localhost:6380") 43 return 1 44 elif args.env == "production": 45 url = os.environ.get("DOCKET_URL_PRODUCTION") 46 if not url: 47 print("error: DOCKET_URL_PRODUCTION not set") 48 + print("hint: flyctl proxy 6381:6379 -a plyr-redis") 49 + print(" export DOCKET_URL_PRODUCTION=redis://localhost:6381") 50 return 1 51 52 print(f"connecting to {args.env}...")
+229
scripts/migrate_sensitive_images.py
···
··· 1 + #!/usr/bin/env -S uv run --script --quiet --with-editable=backend 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = [ 5 + # "httpx", 6 + # "pydantic-settings", 7 + # "asyncpg", 8 + # "sqlalchemy[asyncio]", 9 + # ] 10 + # /// 11 + """migrate sensitive images from backend database to moderation service. 12 + 13 + this script reads sensitive images from the backend database and creates 14 + them in the moderation service. after migration, the backend will proxy 15 + sensitive image requests to the moderation service. 16 + 17 + usage: 18 + uv run scripts/migrate_sensitive_images.py --env prod --dry-run 19 + uv run scripts/migrate_sensitive_images.py --env prod 20 + 21 + environment variables (set in .env or export): 22 + PROD_DATABASE_URL - production database connection string 23 + STAGING_DATABASE_URL - staging database connection string 24 + DEV_DATABASE_URL - development database connection string 25 + MODERATION_SERVICE_URL - URL of moderation service 26 + MODERATION_AUTH_TOKEN - auth token for moderation service 27 + """ 28 + 29 + import argparse 30 + import asyncio 31 + import os 32 + from typing import Literal 33 + 34 + import httpx 35 + from pydantic import Field 36 + from pydantic_settings import BaseSettings, SettingsConfigDict 37 + from sqlalchemy import text 38 + from sqlalchemy.ext.asyncio import create_async_engine 39 + 40 + Environment = Literal["dev", "staging", "prod"] 41 + 42 + 43 + class MigrationSettings(BaseSettings): 44 + """settings for migration script.""" 45 + 46 + model_config = SettingsConfigDict( 47 + env_file=".env", 48 + case_sensitive=False, 49 + extra="ignore", 50 + ) 51 + 52 + dev_database_url: str = Field(default="", validation_alias="DEV_DATABASE_URL") 53 + staging_database_url: str = Field( 54 + default="", validation_alias="STAGING_DATABASE_URL" 55 + ) 56 + prod_database_url: str = Field(default="", validation_alias="PROD_DATABASE_URL") 57 + 58 + moderation_service_url: str = Field( 59 + default="https://moderation.plyr.fm", 60 + validation_alias="MODERATION_SERVICE_URL", 61 + ) 62 + moderation_auth_token: str = Field( 63 + default="", validation_alias="MODERATION_AUTH_TOKEN" 64 + ) 65 + 66 + def get_database_url(self, env: Environment) -> str: 67 + """get database URL for environment.""" 68 + urls = { 69 + "dev": self.dev_database_url, 70 + "staging": self.staging_database_url, 71 + "prod": self.prod_database_url, 72 + } 73 + url = urls.get(env, "") 74 + if not url: 75 + raise ValueError(f"no database URL configured for {env}") 76 + # ensure asyncpg driver is used 77 + if url.startswith("postgresql://"): 78 + url = url.replace("postgresql://", "postgresql+asyncpg://", 1) 79 + return url 80 + 81 + def get_moderation_url(self, env: Environment) -> str: 82 + """get moderation service URL for environment.""" 83 + if env == "dev": 84 + return os.getenv("DEV_MODERATION_URL", "http://localhost:8002") 85 + elif env == "staging": 86 + return os.getenv("STAGING_MODERATION_URL", "https://moderation-stg.plyr.fm") 87 + else: 88 + return self.moderation_service_url 89 + 90 + 91 + async def fetch_sensitive_images(db_url: str) -> list[dict]: 92 + """fetch all sensitive images from backend database.""" 93 + engine = create_async_engine(db_url) 94 + 95 + async with engine.begin() as conn: 96 + result = await conn.execute( 97 + text( 98 + """ 99 + SELECT id, image_id, url, reason, flagged_at, flagged_by 100 + FROM sensitive_images 101 + ORDER BY id 102 + """ 103 + ) 104 + ) 105 + rows = result.fetchall() 106 + 107 + await engine.dispose() 108 + 109 + return [ 110 + { 111 + "id": row[0], 112 + "image_id": row[1], 113 + "url": row[2], 114 + "reason": row[3], 115 + "flagged_at": row[4].isoformat() if row[4] else None, 116 + "flagged_by": row[5], 117 + } 118 + for row in rows 119 + ] 120 + 121 + 122 + async def migrate_to_moderation_service( 123 + images: list[dict], 124 + moderation_url: str, 125 + auth_token: str, 126 + dry_run: bool = False, 127 + ) -> tuple[int, int]: 128 + """migrate images to moderation service. 129 + 130 + returns: 131 + tuple of (success_count, error_count) 132 + """ 133 + success_count = 0 134 + error_count = 0 135 + 136 + headers = {"X-Moderation-Key": auth_token} 137 + 138 + async with httpx.AsyncClient(timeout=30.0) as client: 139 + for image in images: 140 + payload = { 141 + "image_id": image["image_id"], 142 + "url": image["url"], 143 + "reason": image["reason"], 144 + "flagged_by": image["flagged_by"], 145 + } 146 + 147 + if dry_run: 148 + print(f" [dry-run] would migrate: {payload}") 149 + success_count += 1 150 + continue 151 + 152 + try: 153 + response = await client.post( 154 + f"{moderation_url}/admin/sensitive-images", 155 + json=payload, 156 + headers=headers, 157 + ) 158 + response.raise_for_status() 159 + result = response.json() 160 + print(f" migrated id={image['id']} -> moderation id={result['id']}") 161 + success_count += 1 162 + except Exception as e: 163 + print(f" ERROR migrating id={image['id']}: {e}") 164 + error_count += 1 165 + 166 + return success_count, error_count 167 + 168 + 169 + async def main() -> None: 170 + parser = argparse.ArgumentParser( 171 + description="migrate sensitive images to moderation service" 172 + ) 173 + parser.add_argument( 174 + "--env", 175 + choices=["dev", "staging", "prod"], 176 + required=True, 177 + help="environment to migrate", 178 + ) 179 + parser.add_argument( 180 + "--dry-run", 181 + action="store_true", 182 + help="show what would be migrated without making changes", 183 + ) 184 + args = parser.parse_args() 185 + 186 + settings = MigrationSettings() 187 + 188 + print(f"migrating sensitive images for {args.env}") 189 + if args.dry_run: 190 + print("(dry run - no changes will be made)") 191 + 192 + # fetch from backend database 193 + db_url = settings.get_database_url(args.env) 194 + print("\nfetching from backend database...") 195 + images = await fetch_sensitive_images(db_url) 196 + print(f"found {len(images)} sensitive images") 197 + 198 + if not images: 199 + print("nothing to migrate") 200 + return 201 + 202 + # migrate to moderation service 203 + moderation_url = settings.get_moderation_url(args.env) 204 + print(f"\nmigrating to moderation service at {moderation_url}...") 205 + 206 + if not settings.moderation_auth_token and not args.dry_run: 207 + print("ERROR: MODERATION_AUTH_TOKEN not set") 208 + return 209 + 210 + success, errors = await migrate_to_moderation_service( 211 + images, 212 + moderation_url, 213 + settings.moderation_auth_token, 214 + dry_run=args.dry_run, 215 + ) 216 + 217 + print(f"\ndone: {success} migrated, {errors} errors") 218 + 219 + if not args.dry_run and errors == 0: 220 + print( 221 + "\nnext steps:\n" 222 + " 1. verify data in moderation service: GET /sensitive-images\n" 223 + " 2. update backend to proxy to moderation service\n" 224 + " 3. optionally drop sensitive_images table from backend db" 225 + ) 226 + 227 + 228 + if __name__ == "__main__": 229 + asyncio.run(main())
+348
scripts/moderation_loop.py
···
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = [ 5 + # "pydantic-ai>=0.1.0", 6 + # "anthropic", 7 + # "httpx", 8 + # "pydantic>=2.0", 9 + # "pydantic-settings", 10 + # "atproto>=0.0.55", 11 + # "rich", 12 + # ] 13 + # /// 14 + """autonomous moderation loop for plyr.fm. 15 + 16 + workflow: 17 + 1. fetch pending flags from moderation service 18 + 2. analyze each flag with LLM (FALSE_POSITIVE, VIOLATION, NEEDS_HUMAN) 19 + 3. auto-resolve false positives 20 + 4. create review batch for needs_human flags 21 + 5. send DM with link to review UI 22 + 23 + the review UI handles human decisions - DM is just a notification channel. 24 + """ 25 + 26 + import argparse 27 + import asyncio 28 + from dataclasses import dataclass, field 29 + from pathlib import Path 30 + 31 + import httpx 32 + from atproto import AsyncClient, models 33 + from pydantic import BaseModel, Field 34 + from pydantic_ai import Agent 35 + from pydantic_ai.models.anthropic import AnthropicModel 36 + from pydantic_settings import BaseSettings, SettingsConfigDict 37 + from rich.console import Console 38 + 39 + console = Console() 40 + 41 + 42 + class LoopSettings(BaseSettings): 43 + model_config = SettingsConfigDict( 44 + env_file=Path(__file__).parent.parent / ".env", 45 + case_sensitive=False, 46 + extra="ignore", 47 + ) 48 + moderation_service_url: str = Field( 49 + default="https://moderation.plyr.fm", validation_alias="MODERATION_SERVICE_URL" 50 + ) 51 + moderation_auth_token: str = Field( 52 + default="", validation_alias="MODERATION_AUTH_TOKEN" 53 + ) 54 + anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY") 55 + anthropic_model: str = Field( 56 + default="claude-sonnet-4-20250514", validation_alias="ANTHROPIC_MODEL" 57 + ) 58 + bot_handle: str = Field(default="", validation_alias="NOTIFY_BOT_HANDLE") 59 + bot_password: str = Field(default="", validation_alias="NOTIFY_BOT_PASSWORD") 60 + recipient_handle: str = Field( 61 + default="", validation_alias="NOTIFY_RECIPIENT_HANDLE" 62 + ) 63 + 64 + 65 + class FlagAnalysis(BaseModel): 66 + """result of analyzing a single flag.""" 67 + 68 + uri: str 69 + category: str = Field(description="FALSE_POSITIVE, VIOLATION, or NEEDS_HUMAN") 70 + reason: str 71 + 72 + 73 + @dataclass 74 + class DMClient: 75 + handle: str 76 + password: str 77 + recipient_handle: str 78 + _client: AsyncClient = field(init=False, repr=False) 79 + _dm_client: AsyncClient = field(init=False, repr=False) 80 + _recipient_did: str = field(init=False, repr=False) 81 + _convo_id: str = field(init=False, repr=False) 82 + 83 + async def setup(self) -> None: 84 + self._client = AsyncClient() 85 + await self._client.login(self.handle, self.password) 86 + self._dm_client = self._client.with_bsky_chat_proxy() 87 + profile = await self._client.app.bsky.actor.get_profile( 88 + {"actor": self.recipient_handle} 89 + ) 90 + self._recipient_did = profile.did 91 + convo = await self._dm_client.chat.bsky.convo.get_convo_for_members( 92 + models.ChatBskyConvoGetConvoForMembers.Params(members=[self._recipient_did]) 93 + ) 94 + self._convo_id = convo.convo.id 95 + 96 + async def get_messages(self, limit: int = 30) -> list[dict]: 97 + response = await self._dm_client.chat.bsky.convo.get_messages( 98 + models.ChatBskyConvoGetMessages.Params(convo_id=self._convo_id, limit=limit) 99 + ) 100 + return [ 101 + { 102 + "text": m.text, 103 + "is_bot": m.sender.did != self._recipient_did, 104 + "sent_at": getattr(m, "sent_at", None), 105 + } 106 + for m in response.messages 107 + if hasattr(m, "text") and hasattr(m, "sender") 108 + ] 109 + 110 + async def send(self, text: str) -> None: 111 + await self._dm_client.chat.bsky.convo.send_message( 112 + models.ChatBskyConvoSendMessage.Data( 113 + convo_id=self._convo_id, 114 + message=models.ChatBskyConvoDefs.MessageInput(text=text), 115 + ) 116 + ) 117 + 118 + 119 + @dataclass 120 + class PlyrClient: 121 + """client for checking track existence in plyr.fm.""" 122 + 123 + env: str = "prod" 124 + _client: httpx.AsyncClient = field(init=False, repr=False) 125 + 126 + def __post_init__(self) -> None: 127 + base_url = { 128 + "prod": "https://api.plyr.fm", 129 + "staging": "https://api-stg.plyr.fm", 130 + "dev": "http://localhost:8001", 131 + }.get(self.env, "https://api.plyr.fm") 132 + self._client = httpx.AsyncClient(base_url=base_url, timeout=10.0) 133 + 134 + async def close(self) -> None: 135 + await self._client.aclose() 136 + 137 + async def track_exists(self, track_id: int) -> bool: 138 + """check if a track exists (returns False if 404).""" 139 + try: 140 + r = await self._client.get(f"/tracks/{track_id}") 141 + return r.status_code == 200 142 + except Exception: 143 + return True # assume exists on error (don't accidentally delete labels) 144 + 145 + 146 + @dataclass 147 + class ModClient: 148 + base_url: str 149 + auth_token: str 150 + _client: httpx.AsyncClient = field(init=False, repr=False) 151 + 152 + def __post_init__(self) -> None: 153 + self._client = httpx.AsyncClient( 154 + base_url=self.base_url, 155 + headers={"X-Moderation-Key": self.auth_token}, 156 + timeout=30.0, 157 + ) 158 + 159 + async def close(self) -> None: 160 + await self._client.aclose() 161 + 162 + async def list_pending(self) -> list[dict]: 163 + r = await self._client.get("/admin/flags", params={"filter": "pending"}) 164 + r.raise_for_status() 165 + return r.json().get("tracks", []) 166 + 167 + async def resolve(self, uri: str, reason: str, notes: str = "") -> None: 168 + r = await self._client.post( 169 + "/admin/resolve", 170 + json={ 171 + "uri": uri, 172 + "val": "copyright-violation", 173 + "reason": reason, 174 + "notes": notes, 175 + }, 176 + ) 177 + r.raise_for_status() 178 + 179 + async def create_batch( 180 + self, uris: list[str], created_by: str | None = None 181 + ) -> dict: 182 + """create a review batch and return {id, url, flag_count}.""" 183 + r = await self._client.post( 184 + "/admin/batches", 185 + json={"uris": uris, "created_by": created_by}, 186 + ) 187 + r.raise_for_status() 188 + return r.json() 189 + 190 + 191 + def get_header(env: str) -> str: 192 + return f"[PLYR-MOD:{env.upper()}]" 193 + 194 + 195 + def create_flag_analyzer(api_key: str, model: str) -> Agent[None, list[FlagAnalysis]]: 196 + from pydantic_ai.providers.anthropic import AnthropicProvider 197 + 198 + return Agent( 199 + model=AnthropicModel(model, provider=AnthropicProvider(api_key=api_key)), 200 + output_type=list[FlagAnalysis], 201 + system_prompt="""\ 202 + analyze each copyright flag. categorize as: 203 + - FALSE_POSITIVE: fingerprint noise, uploader is the artist, unrelated matches 204 + - VIOLATION: clearly copyrighted commercial content 205 + - NEEDS_HUMAN: ambiguous, need human review 206 + 207 + return a FlagAnalysis for each flag with uri, category, and brief reason. 208 + """, 209 + ) 210 + 211 + 212 + async def run_loop( 213 + dry_run: bool = False, limit: int | None = None, env: str = "prod" 214 + ) -> None: 215 + settings = LoopSettings() 216 + for attr in [ 217 + "moderation_auth_token", 218 + "anthropic_api_key", 219 + "bot_handle", 220 + "bot_password", 221 + "recipient_handle", 222 + ]: 223 + if not getattr(settings, attr): 224 + console.print(f"[red]missing {attr}[/red]") 225 + return 226 + 227 + console.print(f"[bold]moderation loop[/bold] ({settings.anthropic_model})") 228 + if dry_run: 229 + console.print("[yellow]DRY RUN[/yellow]") 230 + 231 + dm = DMClient(settings.bot_handle, settings.bot_password, settings.recipient_handle) 232 + mod = ModClient(settings.moderation_service_url, settings.moderation_auth_token) 233 + plyr = PlyrClient(env=env) 234 + 235 + try: 236 + await dm.setup() 237 + 238 + # get pending flags 239 + pending = await mod.list_pending() 240 + if not pending: 241 + console.print("[green]no pending flags[/green]") 242 + return 243 + 244 + console.print(f"[bold]{len(pending)} pending flags[/bold]") 245 + 246 + # check for deleted tracks and auto-resolve them 247 + console.print("[dim]checking for deleted tracks...[/dim]") 248 + active_flags = [] 249 + deleted_count = 0 250 + for flag in pending: 251 + track_id = flag.get("context", {}).get("track_id") 252 + if track_id and not await plyr.track_exists(track_id): 253 + # track was deleted - resolve the flag 254 + if not dry_run: 255 + try: 256 + await mod.resolve( 257 + flag["uri"], "content_deleted", "track no longer exists" 258 + ) 259 + console.print( 260 + f" [yellow]⌫[/yellow] deleted: {flag['uri'][-40:]}" 261 + ) 262 + deleted_count += 1 263 + except Exception as e: 264 + console.print(f" [red]✗[/red] {e}") 265 + active_flags.append(flag) 266 + else: 267 + console.print( 268 + f" [yellow]would resolve deleted:[/yellow] {flag['uri'][-40:]}" 269 + ) 270 + deleted_count += 1 271 + else: 272 + active_flags.append(flag) 273 + 274 + if deleted_count > 0: 275 + console.print(f"[yellow]{deleted_count} deleted tracks resolved[/yellow]") 276 + 277 + pending = active_flags 278 + if not pending: 279 + console.print("[green]all flags were for deleted tracks[/green]") 280 + return 281 + 282 + # analyze remaining flags 283 + if limit: 284 + pending = pending[:limit] 285 + 286 + analyzer = create_flag_analyzer( 287 + settings.anthropic_api_key, settings.anthropic_model 288 + ) 289 + desc = "\n---\n".join( 290 + f"URI: {f['uri']}\nTrack: {f.get('context', {}).get('track_title', '?')}\n" 291 + f"Uploader: @{f.get('context', {}).get('artist_handle', '?')}\n" 292 + f"Matches: {', '.join(m['artist'] for m in f.get('context', {}).get('matches', [])[:3])}" 293 + for f in pending 294 + ) 295 + result = await analyzer.run(f"analyze {len(pending)} flags:\n\n{desc}") 296 + analyses = result.output 297 + 298 + # auto-resolve false positives 299 + auto = [a for a in analyses if a.category == "FALSE_POSITIVE"] 300 + human = [a for a in analyses if a.category == "NEEDS_HUMAN"] 301 + console.print(f"analysis: {len(auto)} auto-resolve, {len(human)} need human") 302 + 303 + for a in auto: 304 + if not dry_run: 305 + try: 306 + await mod.resolve( 307 + a.uri, "fingerprint_noise", f"auto: {a.reason[:50]}" 308 + ) 309 + console.print(f" [green]✓[/green] {a.uri[-40:]}") 310 + except Exception as e: 311 + console.print(f" [red]✗[/red] {e}") 312 + 313 + # create batch and send link for needs_human (if any) 314 + if human: 315 + human_uris = [h.uri for h in human] 316 + console.print(f"[dim]creating batch for {len(human_uris)} flags...[/dim]") 317 + 318 + if not dry_run: 319 + batch = await mod.create_batch(human_uris, created_by="moderation_loop") 320 + full_url = f"{mod.base_url.rstrip('/')}{batch['url']}" 321 + msg = ( 322 + f"{get_header(env)} {batch['flag_count']} need review:\n{full_url}" 323 + ) 324 + await dm.send(msg) 325 + console.print(f"[green]sent batch {batch['id']}[/green]") 326 + else: 327 + console.print( 328 + f"[yellow]would create batch with {len(human_uris)} flags[/yellow]" 329 + ) 330 + 331 + console.print("[bold]done[/bold]") 332 + 333 + finally: 334 + await mod.close() 335 + await plyr.close() 336 + 337 + 338 + def main() -> None: 339 + parser = argparse.ArgumentParser() 340 + parser.add_argument("--dry-run", action="store_true") 341 + parser.add_argument("--limit", type=int, default=None) 342 + parser.add_argument("--env", default="prod", choices=["dev", "staging", "prod"]) 343 + args = parser.parse_args() 344 + asyncio.run(run_loop(dry_run=args.dry_run, limit=args.limit, env=args.env)) 345 + 346 + 347 + if __name__ == "__main__": 348 + main()