fix: likers tooltip improvements and sensitive content docs (#483)

* docs: add sensitive image moderation to STATUS.md

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

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

* docs: add sensitive content moderation documentation

documents the sensitive image moderation system:
- database schema (sensitive_images table)
- frontend architecture (SSR + client-side)
- SensitiveImage component usage
- matching logic for R2 and external URLs
- API endpoint
- user experience flow
- current limitations (manual flagging only)
- future improvements needed (perceptual hashing, AI detection)
- moderation workflow with SQL examples

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

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

* fix: likers tooltip z-index and compact mode for sensitive avatars

- add z-index to .likes element so tooltip renders above header border
- add compact prop to SensitiveImage for avatars in lists (blur only, no tooltip, preserves layout)
- use compact mode in LikersTooltip to prevent layout breakage on blurred avatars

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

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

---------

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

authored by zzstoatzz.io Claude and committed by GitHub 98cbb1a3 c88727d9

Changed files
+214 -4
docs
frontend
+22
STATUS.md
··· 47 47 48 48 ### December 2025 49 49 50 + #### sensitive image moderation (PRs #471-482, Dec 5) 51 + 52 + **what shipped**: 53 + - `sensitive_images` table to flag problematic images by R2 `image_id` or external URL 54 + - `show_sensitive_artwork` user preference (default: hidden, toggle in portal → "your data") 55 + - flagged images blurred everywhere: track lists, player, artist pages, likers tooltip 56 + - SSR-safe filtering: link previews (og:image) exclude sensitive images 57 + - likers tooltip improvements: max-height with scroll, hover interaction fix 58 + 59 + **how it works**: 60 + - frontend fetches `/moderation/sensitive-images` and stores flagged IDs/URLs 61 + - `SensitiveImage` component wraps images and checks against flagged list 62 + - server-side check via `+layout.server.ts` for meta tag filtering 63 + - users can opt-in to view sensitive artwork via portal toggle 64 + 65 + **moderation workflow**: 66 + - admin adds row to `sensitive_images` with `image_id` (R2) or `url` (external) 67 + - images are blurred immediately for all users 68 + - users who enable `show_sensitive_artwork` see unblurred images 69 + 70 + --- 71 + 50 72 #### teal.fm scrobbling integration (PR #467, Dec 4) 51 73 52 74 **what shipped**:
+180
docs/moderation/sensitive-content.md
··· 1 + # sensitive content moderation 2 + 3 + ## overview 4 + 5 + plyr.fm allows artists to upload cover art and use their Bluesky avatars. some of this content may be inappropriate for general audiences (nudity, graphic imagery, etc.). rather than blocking uploads, we blur sensitive images by default and let users opt-in to view them. 6 + 7 + this follows our core moderation philosophy: **information, not enforcement**. we flag content and let users decide what they want to see. 8 + 9 + ## current implementation 10 + 11 + ### database schema 12 + 13 + ```sql 14 + -- tracks flagged images 15 + CREATE TABLE sensitive_images ( 16 + id SERIAL PRIMARY KEY, 17 + image_id VARCHAR, -- R2 image ID (for uploaded images) 18 + url TEXT, -- full URL (for external images like Bluesky avatars) 19 + reason VARCHAR, -- why flagged: 'nudity', 'violence', etc. 20 + flagged_at TIMESTAMPTZ, 21 + flagged_by VARCHAR -- admin identifier 22 + ); 23 + 24 + -- user preference 25 + ALTER TABLE user_preferences ADD COLUMN show_sensitive_artwork BOOLEAN DEFAULT false; 26 + ``` 27 + 28 + images can be flagged by either: 29 + - `image_id` - for images uploaded to R2 (track artwork, album covers) 30 + - `url` - for external images (Bluesky avatars synced from PDS) 31 + 32 + ### frontend architecture 33 + 34 + ``` 35 + ┌─────────────────────────────────────────────────────────────────┐ 36 + │ page load │ 37 + └─────────────────────────────────────────────────────────────────┘ 38 + 39 + ┌─────────────────────┼─────────────────────┐ 40 + │ │ │ 41 + ▼ ▼ ▼ 42 + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ 43 + │ +layout.server│ │ +layout.ts │ │ +layout.svelte│ 44 + │ fetch flagged │ │ pass through │ │ init client │ 45 + │ images (SSR) │ │ to pages │ │ moderation │ 46 + └───────────────┘ └───────────────┘ └───────────────┘ 47 + │ │ 48 + ▼ ▼ 49 + ┌───────────────┐ ┌───────────────┐ 50 + │ meta tags use │ │ SensitiveImage│ 51 + │ SSR data │ │ component uses│ 52 + │ (link preview)│ │ client store │ 53 + └───────────────┘ └───────────────┘ 54 + ``` 55 + 56 + two-pronged approach: 57 + 1. **SSR** - `+layout.server.ts` fetches flagged images for meta tag filtering (link previews) 58 + 2. **client** - moderation store fetches same data for runtime blur effect 59 + 60 + ### SensitiveImage component 61 + 62 + wraps any image that might need blurring: 63 + 64 + ```svelte 65 + <SensitiveImage src={imageUrl}> 66 + <img src={imageUrl} alt="..." /> 67 + </SensitiveImage> 68 + ``` 69 + 70 + the component: 71 + - checks if `src` matches any flagged image 72 + - applies CSS blur filter if flagged 73 + - shows tooltip on hover: "sensitive - enable in portal" 74 + - respects user's `show_sensitive_artwork` preference 75 + 76 + ### matching logic 77 + 78 + ```typescript 79 + function checkImageSensitive(url: string, data: SensitiveImagesData): boolean { 80 + // exact URL match (for external images) 81 + if (data.urls.includes(url)) return true; 82 + 83 + // R2 image ID extraction and match 84 + const r2Match = url.match(/r2\.dev\/([^/.]+)\./); 85 + if (r2Match && data.image_ids.includes(r2Match[1])) return true; 86 + 87 + const cdnMatch = url.match(/\/images\/([^/.]+)\./); 88 + if (cdnMatch && data.image_ids.includes(cdnMatch[1])) return true; 89 + 90 + return false; 91 + } 92 + ``` 93 + 94 + ### API endpoint 95 + 96 + ``` 97 + GET /moderation/sensitive-images 98 + 99 + Response: 100 + { 101 + "image_ids": ["abc123", "def456"], 102 + "urls": ["https://cdn.bsky.app/..."] 103 + } 104 + ``` 105 + 106 + returns arrays for SSR compatibility (Sets don't serialize to JSON). 107 + 108 + ## user experience 109 + 110 + ### default behavior 111 + 112 + - all images matching `sensitive_images` table are blurred 113 + - tooltip on hover explains how to enable 114 + - link previews (og:image) exclude sensitive images entirely 115 + 116 + ### opt-in flow 117 + 118 + 1. user navigates to portal → "your data" 119 + 2. toggles "sensitive artwork" to enabled 120 + 3. `show_sensitive_artwork` preference saved to database 121 + 4. all sensitive images immediately unblur 122 + 123 + ## current limitations 124 + 125 + ### manual flagging only 126 + 127 + the `sensitive_images` table is currently populated manually by admins. this is "whack-a-mole" moderation - we flag images as we discover them. 128 + 129 + **future improvements needed:** 130 + 131 + 1. **perceptual hashing** - hash images at upload time, detect re-uploads of flagged content 132 + 2. **AI detection** - integrate NSFW detection API (AWS Rekognition, Google Vision, etc.) 133 + 3. **user reporting** - let users flag inappropriate content 134 + 4. **artist self-labeling** - let artists mark their own content as sensitive 135 + 136 + ### no ATProto labels yet 137 + 138 + unlike copyright moderation, sensitive content flags don't emit ATProto labels. this is intentional for now - we're still figuring out the right taxonomy for content labels vs. copyright labels. 139 + 140 + future work might include: 141 + - `content-warning` label type 142 + - integration with Bluesky's existing content label system 143 + - respecting labels from other ATProto services 144 + 145 + ## moderation workflow 146 + 147 + ### current process 148 + 149 + 1. admin discovers inappropriate image (user report, browsing, etc.) 150 + 2. admin identifies the image source: 151 + - R2 upload: extract `image_id` from URL 152 + - external: copy full URL 153 + 3. admin inserts row into `sensitive_images` via SQL or Neon console 154 + 4. image is immediately blurred for all users 155 + 156 + ### example: flagging an R2 image 157 + 158 + ```sql 159 + -- image URL: https://pub-xxx.r2.dev/images/abc123.jpg 160 + INSERT INTO sensitive_images (image_id, reason, flagged_by) 161 + VALUES ('abc123', 'nudity', 'admin'); 162 + ``` 163 + 164 + ### example: flagging a Bluesky avatar 165 + 166 + ```sql 167 + -- avatar URL from artist profile 168 + INSERT INTO sensitive_images (url, reason, flagged_by) 169 + VALUES ( 170 + 'https://cdn.bsky.app/img/avatar/plain/did:plc:xxx/bafkrei...@jpeg', 171 + 'nudity', 172 + 'admin' 173 + ); 174 + ``` 175 + 176 + ## related documentation 177 + 178 + - [overview.md](./overview.md) - moderation philosophy and architecture 179 + - [copyright-detection.md](./copyright-detection.md) - automated copyright scanning 180 + - [atproto-labeler.md](./atproto-labeler.md) - ATProto label emission
+1 -1
frontend/src/lib/components/LikersTooltip.svelte
··· 85 85 class="liker" 86 86 > 87 87 {#if liker.avatar_url} 88 - <SensitiveImage src={liker.avatar_url}> 88 + <SensitiveImage src={liker.avatar_url} compact> 89 89 <img src={liker.avatar_url} alt={liker.display_name} class="avatar" /> 90 90 </SensitiveImage> 91 91 {:else}
+10 -3
frontend/src/lib/components/SensitiveImage.svelte
··· 9 9 children: import('svelte').Snippet; 10 10 /** tooltip position - 'above' for small images, 'center' for large */ 11 11 tooltipPosition?: 'above' | 'center'; 12 + /** compact mode - blur only, no tooltip, preserves layout (for avatars in lists) */ 13 + compact?: boolean; 12 14 } 13 15 14 - let { src, children, tooltipPosition = 'above' }: Props = $props(); 16 + let { src, children, tooltipPosition = 'above', compact = false }: Props = $props(); 15 17 16 18 let isSensitive = $derived(moderation.isSensitive(src)); 17 19 let shouldBlur = $derived(isSensitive && !preferences.showSensitiveArtwork); 18 20 </script> 19 21 20 - <div class="sensitive-wrapper" class:blur={shouldBlur} class:tooltip-center={tooltipPosition === 'center'}> 22 + <div class="sensitive-wrapper" class:blur={shouldBlur} class:compact class:tooltip-center={tooltipPosition === 'center'}> 21 23 {@render children()} 22 - {#if shouldBlur} 24 + {#if shouldBlur && !compact} 23 25 <div class="sensitive-tooltip"> 24 26 <span>sensitive - enable in portal</span> 25 27 </div> ··· 35 37 .sensitive-wrapper.blur { 36 38 display: block; 37 39 position: relative; 40 + } 41 + 42 + /* compact mode: preserve layout, just blur the image */ 43 + .sensitive-wrapper.blur.compact { 44 + display: contents; 38 45 } 39 46 40 47 .sensitive-wrapper.blur :global(img) {
+1
frontend/src/lib/components/TrackItem.svelte
··· 616 616 position: relative; 617 617 cursor: help; 618 618 transition: color 0.2s; 619 + z-index: 100; 619 620 } 620 621 621 622 .likes:hover {