feat: render links in artist bio (#700)

* feat: render links in artist bio

Adds RichText component that auto-detects and renders:
- bare URLs (https://example.com, www.example.com)
- markdown-style links ([text](url))

Links open in new tab with proper security attributes.

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

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

* fix: align link hover style with existing patterns

remove opacity transition to match .comment-link styling.
add word-break: break-all for long URLs.

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

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

---------

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

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub ea576f27 2496133d

Changed files
+102 -1
frontend
src
lib
components
routes
u
[handle]
+100
frontend/src/lib/components/RichText.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * renders text with auto-linked URLs and markdown-style links. 4 + * 5 + * supports: 6 + * - bare URLs: https://example.com -> clickable link 7 + * - markdown links: [text](https://example.com) -> "text" as clickable link 8 + */ 9 + 10 + interface Props { 11 + text: string; 12 + class?: string; 13 + } 14 + 15 + let { text, class: className }: Props = $props(); 16 + 17 + interface TextPart { 18 + type: 'text' | 'link'; 19 + content: string; 20 + href?: string; 21 + } 22 + 23 + function parseText(input: string): TextPart[] { 24 + const parts: TextPart[] = []; 25 + 26 + // combined regex: markdown links OR bare URLs 27 + // markdown: [text](url) 28 + // bare URL: https?://... or www.... 29 + const combinedRegex = 30 + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>)\]]+|www\.[^\s<>)\]]+)/gi; 31 + 32 + let lastIndex = 0; 33 + let match; 34 + 35 + while ((match = combinedRegex.exec(input)) !== null) { 36 + // add text before match 37 + if (match.index > lastIndex) { 38 + parts.push({ 39 + type: 'text', 40 + content: input.slice(lastIndex, match.index) 41 + }); 42 + } 43 + 44 + if (match[1] && match[2]) { 45 + // markdown link: [text](url) 46 + parts.push({ 47 + type: 'link', 48 + content: match[1], 49 + href: match[2] 50 + }); 51 + } else if (match[3]) { 52 + // bare URL 53 + let href = match[3]; 54 + if (href.startsWith('www.')) { 55 + href = 'https://' + href; 56 + } 57 + parts.push({ 58 + type: 'link', 59 + content: match[3], 60 + href 61 + }); 62 + } 63 + 64 + lastIndex = match.index + match[0].length; 65 + } 66 + 67 + // add remaining text 68 + if (lastIndex < input.length) { 69 + parts.push({ 70 + type: 'text', 71 + content: input.slice(lastIndex) 72 + }); 73 + } 74 + 75 + return parts; 76 + } 77 + 78 + const parsed = $derived(parseText(text)); 79 + </script> 80 + 81 + <span class={className} 82 + >{#each parsed as part}{#if part.type === 'link'}<a 83 + href={part.href} 84 + target="_blank" 85 + rel="noopener noreferrer" 86 + class="rich-text-link">{part.content}</a 87 + >{:else}{part.content}{/if}{/each}</span 88 + > 89 + 90 + <style> 91 + .rich-text-link { 92 + color: var(--accent); 93 + text-decoration: none; 94 + word-break: break-all; 95 + } 96 + 97 + .rich-text-link:hover { 98 + text-decoration: underline; 99 + } 100 + </style>
+2 -1
frontend/src/routes/u/[handle]/+page.svelte
··· 9 9 import Header from '$lib/components/Header.svelte'; 10 10 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 11 11 import SupporterBadge from '$lib/components/SupporterBadge.svelte'; 12 + import RichText from '$lib/components/RichText.svelte'; 12 13 import { checkImageSensitive } from '$lib/moderation.svelte'; 13 14 import { player } from '$lib/player.svelte'; 14 15 import { queue } from '$lib/queue.svelte'; ··· 398 399 {/if} 399 400 </div> 400 401 {#if artist.bio} 401 - <p class="bio">{artist.bio}</p> 402 + <p class="bio"><RichText text={artist.bio} /></p> 402 403 {/if} 403 404 </div> 404 405 <div class="artist-actions-desktop">