Merge pull request #8 from jedmund/refine/apple-music

Apple Music support
This commit is contained in:
Justin Edmund 2025-06-14 07:49:42 -07:00 committed by GitHub
commit a9ec023bae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 2816 additions and 323 deletions

View file

@ -13,4 +13,12 @@ CLOUDINARY_API_KEY="your-api-key"
CLOUDINARY_API_SECRET="your-api-secret"
# Admin Authentication (for later)
ADMIN_PASSWORD="your-admin-password"
ADMIN_PASSWORD="your-admin-password"
# Apple Music API
APPLE_MUSIC_TEAM_ID="your-team-id"
APPLE_MUSIC_KEY_ID="your-key-id"
# For local development, use path:
APPLE_MUSIC_PRIVATE_KEY_PATH="path/to/your/private-key.p8"
# For production, paste the entire .p8 file content:
# APPLE_MUSIC_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByq...\n-----END PRIVATE KEY-----"

4
.gitignore vendored
View file

@ -16,6 +16,10 @@ Thumbs.db
!.env.example
!.env.test
# Apple Music Private Keys
keys/
*.p8
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

104
package-lock.json generated
View file

@ -36,6 +36,7 @@
"@tiptap/pm": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/multer": "^1.4.12",
"@types/redis": "^4.0.10",
"@types/steamapi": "^2.2.5",
@ -45,6 +46,7 @@
"giantbombing-api": "^1.0.4",
"gray-matter": "^4.0.3",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"katex": "^0.16.22",
"lowlight": "^3.3.0",
"lucide-svelte": "^0.511.0",
@ -2875,6 +2877,15 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@ -2910,6 +2921,11 @@
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
"node_modules/@types/multer": {
"version": "1.4.12",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz",
@ -3655,6 +3671,11 @@
"node": ">=8.0.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -4305,6 +4326,14 @@
"safer-buffer": "^2.1.0"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.4.827",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.827.tgz",
@ -5682,6 +5711,27 @@
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC"
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsprim": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
@ -5697,6 +5747,25 @@
"node": ">=0.6.0"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/katex": {
"version": "0.16.22",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
@ -5823,18 +5892,53 @@
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"node_modules/loupe": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",

View file

@ -81,6 +81,7 @@
"@tiptap/pm": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/multer": "^1.4.12",
"@types/redis": "^4.0.10",
"@types/steamapi": "^2.2.5",
@ -90,6 +91,7 @@
"giantbombing-api": "^1.0.4",
"gray-matter": "^4.0.3",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"katex": "^0.16.22",
"lowlight": "^3.3.0",
"lucide-svelte": "^0.511.0",

View file

@ -1,18 +1,22 @@
# Product Requirements Document: URL Embed Functionality
## Overview
This PRD outlines the implementation of URL paste functionality in the Editor that allows users to choose between displaying URLs as rich embed cards or simple links.
## Background
Currently, the Editor supports various content types including text, images, and code blocks. Adding URL embed functionality will enhance the content creation experience by allowing users to share links with rich previews that include titles, descriptions, and images from the linked content.
## Goals
1. Enable users to paste URLs and automatically convert them to rich embed cards
2. Provide flexibility to display URLs as either embed cards or simple links
3. Maintain consistency with existing UI/UX patterns
4. Ensure performance with proper loading states and error handling
## User Stories
1. **As a content creator**, I want to paste a URL and have it automatically display as a rich preview card so that my content is more engaging.
2. **As a content creator**, I want to be able to choose between an embed card and a simple link so that I have control over how my content appears.
3. **As a content creator**, I want to edit or remove URL embeds after adding them so that I can correct mistakes or update content.
@ -21,6 +25,7 @@ Currently, the Editor supports various content types including text, images, and
## Functional Requirements
### URL Detection and Conversion
1. **Automatic Detection**: When a user pastes a plain URL (e.g., `https://example.com`), the system should:
- Create a regular text link initially
- Display a dropdown menu next to the cursor with the option to "Convert to embed"
@ -32,6 +37,7 @@ Currently, the Editor supports various content types including text, images, and
- Direct input in placeholder
### Embed Card Display
1. **Metadata Fetching**: The system should fetch OpenGraph metadata including:
- Title
- Description
@ -46,6 +52,7 @@ Currently, the Editor supports various content types including text, images, and
3. **Fallback**: If metadata fetching fails, display a simple card with the URL
### User Interactions
1. **In-Editor Actions**:
- Refresh metadata
- Open link in new tab
@ -55,6 +62,7 @@ Currently, the Editor supports various content types including text, images, and
3. **Error Handling**: Display user-friendly error messages
### Content Rendering
1. **Editor View**: Full interactive embed with action buttons
2. **Published View**: Static card with clickable elements
3. **Responsive Design**: Cards should adapt to different screen sizes
@ -62,12 +70,15 @@ Currently, the Editor supports various content types including text, images, and
## Technical Implementation
### Architecture
1. **TipTap Extensions**:
- `UrlEmbed`: Main node extension for URL detection and schema
- `UrlEmbedPlaceholder`: Temporary node during loading
- `UrlEmbedExtended`: Final node with metadata
2. **Components**:
- `UrlEmbedPlaceholder.svelte`: Loading/input UI
- `UrlEmbedExtended.svelte`: Rich preview card
@ -76,29 +87,33 @@ Currently, the Editor supports various content types including text, images, and
- Implement caching to reduce redundant fetches
### Data Model
```typescript
interface UrlEmbedNode {
type: 'urlEmbed';
attrs: {
url: string;
title?: string;
description?: string;
image?: string;
siteName?: string;
favicon?: string;
};
type: 'urlEmbed'
attrs: {
url: string
title?: string
description?: string
image?: string
siteName?: string
favicon?: string
}
}
```
## UI/UX Specifications
### Visual Design
- Match existing `LinkCard` component styling
- Use established color variables and spacing
- Maintain consistency with overall site design
### Interaction Patterns
1. **Paste Flow**:
- User pastes URL
- URL appears as regular link text
- Dropdown menu appears next to cursor with "Convert to embed" option
@ -116,23 +131,27 @@ interface UrlEmbedNode {
- Same loading/rendering flow as paste
## Performance Considerations
1. **Lazy Loading**: Only fetch metadata when URL is added
2. **Caching**: Cache fetched metadata to avoid redundant API calls
3. **Timeout**: Implement reasonable timeout for metadata fetching
4. **Image Optimization**: Consider lazy loading preview images
## Security Considerations
1. **URL Validation**: Validate URLs before fetching metadata
2. **Content Sanitization**: Sanitize fetched metadata to prevent XSS
3. **CORS Handling**: Properly handle cross-origin requests
## Success Metrics
1. **Adoption Rate**: Percentage of posts using URL embeds
2. **Error Rate**: Frequency of metadata fetch failures
3. **Performance**: Average time to fetch and display metadata
4. **User Satisfaction**: Feedback on embed functionality
## Future Enhancements
1. **Custom Previews**: Allow manual editing of metadata
2. **Platform-Specific Embeds**: Special handling for YouTube, Twitter, etc.
3. **Embed Templates**: Different card styles for different content types
@ -140,9 +159,11 @@ interface UrlEmbedNode {
## Timeline
### Phase 1: Core Functionality
**Status**: In Progress
#### Completed Tasks:
- [x] Create TipTap extension for URL detection (`UrlEmbed.ts`)
- [x] Create placeholder component for loading state (`UrlEmbedPlaceholder.svelte`)
- [x] Create extended component for rich preview (`UrlEmbedExtended.svelte`)
@ -154,6 +175,7 @@ interface UrlEmbedNode {
- [x] Add content rendering for published posts
#### Remaining Tasks:
- [x] Implement paste detection with dropdown menu
- [x] Create dropdown component for "Convert to embed" option
- [x] Add convert between embed/link functionality
@ -163,21 +185,26 @@ interface UrlEmbedNode {
- [ ] Update documentation
### Phase 2: Platform-Specific Embeds
**Status**: Future
- [ ] YouTube video embeds with player
- [ ] Twitter/X post embeds
- [ ] Instagram post embeds
- [ ] GitHub repository/gist embeds
### Phase 3: Advanced Customization
**Status**: Future
- [ ] Custom preview editing
- [ ] Multiple embed templates/styles
- [ ] Embed size options (compact/full)
- [ ] Custom CSS for embeds
## Dependencies
- Existing `/api/og-metadata` endpoint
- TipTap editor framework
- Svelte 5 with runes mode
- Existing design system and CSS variables
- Existing design system and CSS variables

View file

@ -1,3 +1,3 @@
/* Global styles for the entire application */
@import './assets/styles/reset.css';
@import './assets/styles/globals.scss';
@import './assets/styles/globals.scss';

View file

@ -0,0 +1,17 @@
<svg width="86" height="202" viewBox="0 0 86 202" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M56.6854 195.667C63.0485 191.195 71.8296 179.809 77.087 168.9C89.0428 144.091 92.2289 102.907 65.7859 86.2014C45.9684 73.9065 19.7285 85.9377 9.43434 106.872C2.48924 121.207 -0.100899 137.51 0.00299303 151.553C0.0550549 158.587 0.783319 165.136 2.01973 170.671C3.2404 176.136 5.01771 180.892 7.33744 184.111L7.42625 184.234L7.52449 184.348C13.8609 191.696 24.8872 200.228 37.3464 201.171C43.6642 201.649 50.288 200.162 56.6854 195.667Z" fill="#D9D9D9" style="fill:#D9D9D9;fill:color(display-p3 0.8510 0.8510 0.8510);fill-opacity:1;"/>
<path d="M59.5 190.756C69.2084 179.099 75.5 169.756 79.5 158.256C55.0216 178.249 18.4869 138.632 7 122.256L2.5 150.256L6 175.756L14 188.756C22.988 194.631 28.2191 197.535 39.5 198.756C47.3937 198.19 51.7776 196.59 59.5 190.756Z" fill="#C4C4C4" style="fill:#C4C4C4;fill:color(display-p3 0.7675 0.7675 0.7675);fill-opacity:1;"/>
<path d="M65.7859 86.2014C92.2289 102.907 89.0428 144.091 77.087 168.9C71.8296 179.809 63.0485 191.195 56.6854 195.667C50.288 200.162 43.6642 201.649 37.3464 201.171C24.8872 200.228 14 193.756 7.52449 184.348L7.42625 184.234L7.33744 184.111C5.01771 180.892 3.2404 176.136 2.01973 170.671C0.783319 165.136 0.0550549 158.587 0.00299303 151.553C-0.100899 137.51 2.48924 121.207 9.43434 106.872C19.7285 85.9377 45.9684 73.9065 65.7859 86.2014ZM62.7309 92.4309C18.5 64.7561 -5.5 152.756 14 184.111C19.5 191.756 26.5 195.256 37 196.756C44.1521 197.778 49.5 196.256 55.5 191.756C88.5 161.756 91.5 115.756 62.7309 92.4309Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<ellipse cx="52.9243" cy="148.889" rx="23.6644" ry="34.6366" transform="rotate(15.2136 52.9243 148.889)" fill="url(#paint0_linear_2705_199)" style=""/>
<path d="M61.8821 115.948C67.9817 117.607 72.5741 122.742 75.0255 129.81C77.4759 136.875 77.7642 145.823 75.2772 154.968C72.7902 164.113 68.0103 171.682 62.3192 176.533C56.6259 181.386 50.0655 183.488 43.9659 181.829C37.8664 180.17 33.2739 175.035 30.8225 167.968C28.3721 160.902 28.0848 151.955 30.5717 142.81C33.0587 133.665 37.8377 126.096 43.5288 121.245C49.2221 116.392 55.7825 114.29 61.8821 115.948Z" stroke="black" stroke-opacity="0.04" style="stroke:black;stroke-opacity:0.04;"/>
<path d="M63.4999 105.756C59.6929 111.276 52.1372 110.743 52.4999 105.756C54.5 78.256 48.8535 51.051 44.4999 31.756C43.2386 26.1657 50.0012 22.3638 56.4999 31.756C65.6128 44.9263 69.8913 96.4896 63.4999 105.756Z" fill="#CCCCCC" style="fill:#CCCCCC;fill:color(display-p3 0.8000 0.8000 0.8000);fill-opacity:1;"/>
<path d="M47 24.756C48.5 24.256 51 23.7561 53.231 25.7452C73.5171 46.5549 69.6524 96.6664 65.4819 105.987C64.4309 110.126 60.9683 112.483 57.8619 112.256C56.3101 112.143 54.7629 111.597 53.5962 110.62C52.3998 109.618 50.8727 107.446 51.0035 105.648L51.1763 103.081C52.777 76.9845 49.876 50.8066 43.4175 33.1486C42.1049 29.6189 43.5884 26.4618 47 24.756ZM52 28.256C50.8336 26.9568 49.2098 26.1611 47.5 26.756C44.6798 27.7374 44.5 30.756 45.5 32.756C51.752 42.5329 55.5593 69.5264 54.1695 103.273L53.9956 105.865C53.9453 106.56 54.2225 107.143 54.7964 107.624C55.4003 108.13 56.3169 108.492 57.354 108.568C59.4256 108.719 61.447 107.747 62.0464 105.387C65.1582 98.5154 69.6073 47.8691 52 28.256Z" fill="black" style="fill:black;fill-opacity:1;"/>
<path d="M50.5 22.2561L47.5 38.2561L44.5 42.2561L41 40.7561L36 26.2561L45.5 14.2561L50.5 22.2561Z" fill="#935C0A" style="fill:#935C0A;fill:color(display-p3 0.5765 0.3608 0.0392);fill-opacity:1;"/>
<path d="M11.8771 5.23835C11.3044 4.46433 11.4186 3.36941 12.216 2.82978C20.0877 -2.49724 33.4255 -0.56012 32.1836 10.8355C53.6363 5.53796 57.7794 29.8565 47.8743 41.3154C47.3637 41.9061 46.5303 42.0748 45.8019 41.7937V41.7937C44.3261 41.2242 44.022 39.123 44.9497 37.8418C51.4507 28.8638 48.3771 9.19044 29.916 17.316L28.5706 18.241C26.9453 19.3584 24.8549 17.6975 25.576 15.8616L26.1729 14.3424C29.7019 2.94735 22.8674 0.0733882 14.5317 5.64813C13.6712 6.22364 12.4928 6.07057 11.8771 5.23835V5.23835Z" fill="black" style="fill:black;fill-opacity:1;"/>
<defs>
<linearGradient id="paint0_linear_2705_199" x1="52.9243" y1="114.252" x2="59.7348" y2="183.17" gradientUnits="userSpaceOnUse">
<stop stop-color="#BABABA" style="stop-color:#BABABA;stop-color:color(display-p3 0.7275 0.7275 0.7275);stop-opacity:1;"/>
<stop offset="0.829668" stop-color="#B3B3B3" style="stop-color:#B3B3B3;stop-color:color(display-p3 0.7020 0.7020 0.7020);stop-opacity:1;"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,59 @@
<svg width="497" height="497" viewBox="0 0 497 497" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2705_62)">
<path d="M497 0H0V497H497V0Z" fill="#FF2602" style="fill:#FF2602;fill:color(display-p3 1.0000 0.1490 0.0078);fill-opacity:1;"/>
<path d="M155.92 505C155.92 505 171.19 456 215.69 439C234.91 431.66 269.19 429.5 291.92 441.5C305.42 448.63 312.92 461.5 316.92 469.73C322.375 480.659 324.956 492.797 324.42 505H155.92Z" fill="#BCBCBC" style="fill:#BCBCBC;fill:color(display-p3 0.7373 0.7373 0.7373);fill-opacity:1;"/>
<path d="M264.33 432.17C292.5 432.17 327.21 453.17 331.83 493.17C332.98 503.06 333.12 505.88 333.12 505.88H322.5C322.5 505.88 325.65 444.08 265 434.83C245.92 431.92 179.5 445.83 158.33 505.88H145.83C145.83 505.88 157 450 226.5 427.5C251.06 420.94 264.33 432.17 264.33 432.17Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M296 505.88C296 505.88 295.5 494.62 296.75 489.5C298 484.38 295.88 481.5 293.75 481.12C291.62 480.74 288.88 481.88 287.88 486.25C286.657 492.722 286.028 499.293 286 505.88H296Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M131.81 226.25C131.81 226.25 137.14 255.25 136.81 269.08C136.81 269.08 134.48 293.91 129.81 312.25C125.14 330.59 120.81 367.91 126.14 384.25C131.47 400.59 153.48 430.25 194.48 424.25C235.48 418.25 237.14 416.58 236.81 426.58C236.48 436.58 234.5 441.67 230.5 448C230.5 448 244.81 455.25 251.48 450.58L258.14 445.91C258.14 445.91 258.54 434.27 261.48 427.25C263.48 422.45 266.14 422.25 266.14 422.25C266.14 422.25 300.81 413.91 320.14 396.58C335.67 382.66 351.48 356.25 354.48 318.91C354.48 318.91 366.81 313.25 368.48 307.25L370.14 301.25C370.14 301.25 370.48 294.91 364.81 295.91C359.14 296.91 353.81 300.91 353.81 300.91C353.81 300.91 360.81 242.25 352.14 212.91C347.14 195.98 338.36 177.64 312.14 160.25C276.48 136.58 189.48 139.25 142.81 176.91C125.16 191.16 131.81 226.25 131.81 226.25Z" fill="#C3915E" style="fill:#C3915E;fill:color(display-p3 0.7647 0.5686 0.3686);fill-opacity:1;"/>
<path d="M135.85 280C135.85 280 147.71 272.65 150.67 265.34C162.35 271.34 181.57 271.34 184.29 265.34C187.01 259.34 184.38 254.16 180.66 251.91C184 253.1 186.25 251.1 186.66 248.54C190.35 251.54 208.22 249.42 212.53 242.16C215.72 245.66 232.41 244.44 237.35 237.85C241.47 244.66 266.28 245.79 274.35 236.16C274.35 236.16 290.69 223.05 277.2 197.33C263.71 171.61 151 174.67 135.85 202.13C120.7 229.59 118.08 255.68 135.85 280Z" fill="#AD723B" style="fill:#AD723B;fill:color(display-p3 0.6784 0.4471 0.2314);fill-opacity:1;"/>
<path d="M245.54 452.73C245.54 452.73 243.72 436 254 424.68C265 412.58 289 411.08 302.7 386.88C302.7 386.88 298.2 373.62 307.2 361.62C307.2 361.62 295.01 340 320.2 329.62L355.07 323.09C355.07 323.09 353.37 350.17 337.2 375.38C331.04 386.44 317.2 406.67 274.83 420.12C274.83 420.12 262.33 423.38 261.04 430.4C260.212 434.793 260.104 439.292 260.72 443.72C260.778 444.106 260.737 444.5 260.6 444.866C260.464 445.232 260.237 445.557 259.94 445.81C255.829 449.338 250.863 451.724 245.54 452.73V452.73Z" fill="#AD723B" style="fill:#AD723B;fill:color(display-p3 0.6784 0.4471 0.2314);fill-opacity:1;"/>
<path d="M178.25 378.94C198 379.69 231.62 374.44 231.62 374.44C231.62 374.44 232 363.88 235.5 360C239 356.12 241.12 358.12 241.12 358.12C241.12 358.12 244 359.5 241.12 364.12C238.24 368.74 238.56 375.38 239.31 378.56C240.06 381.74 245 389.5 245 389.5C245.033 389.662 245.02 389.83 244.964 389.986C244.907 390.141 244.809 390.278 244.68 390.381C244.551 390.485 244.396 390.551 244.232 390.571C244.068 390.592 243.901 390.568 243.75 390.5C242.38 390.12 241.12 391.75 241.12 391.75C241.12 391.75 240.38 392.61 238.88 390.88C236.122 387.537 233.945 383.754 232.44 379.69C232.44 379.69 203.86 385.31 177.92 384.31C177.92 384.31 175.81 384.69 175.06 381.56C174.35 378.6 178.25 378.94 178.25 378.94Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M216.12 299C216.12 296.37 216.28 295 218.72 294.87C221.16 294.74 221.06 297.75 221.06 297.75C221.06 297.75 220.67 312.91 215.06 326.75C215.06 326.75 212.19 334.19 210.12 334.19C209.084 334.438 207.994 334.317 207.038 333.848C206.081 333.379 205.318 332.591 204.88 331.62C204.88 331.62 203.56 328.07 202.16 327.97C199.59 327.78 197.96 332.58 197.6 336.23C197.38 338.5 196.44 339.94 194.12 339.94C191.8 339.94 190.94 338.55 191.19 336.12C192.19 329.94 196.47 322.91 199.44 319.94C199.728 319.646 200.072 319.411 200.451 319.25C200.831 319.088 201.238 319.004 201.65 319C204.75 319 205.65 321.67 206.07 323.89C206.81 327.75 208.12 327.47 208.12 327.47C208.12 327.47 208.7 327.67 209.5 326.06C212.94 319.12 216.12 307.44 216.12 299Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M267.4 284.57C267.49 282.69 268.84 279.57 271.88 280C274.25 280.31 275.69 282 275.31 285.5C274.88 289.5 271.47 306.69 268.86 311.25C267.37 313.86 266 314.44 263.86 313.96C262.86 313.73 261.73 312.44 262.28 309.06C263 304.59 266.77 296.57 267.4 284.57Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M168.91 277.88C168.91 277.88 170.91 277.81 171.29 280.53C172.1 285.64 171.55 295.73 169.64 304.1C169.64 304.1 168.27 307.92 165.46 307.92C162.65 307.92 162.29 304.75 162.36 302.66C162.43 300.57 165.1 294.59 165.1 283.86C165.1 277.08 168.91 277.88 168.91 277.88Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M253.28 261.12C253.22 267.12 259.28 271.5 269.88 271.75C280.48 272 285.55 270.58 290.88 265.67C296.21 260.76 280.36 256.94 271.04 257.26C261.72 257.58 253.31 258 253.28 261.12Z" fill="#AD723B" style="fill:#AD723B;fill:color(display-p3 0.6784 0.4471 0.2314);fill-opacity:1;"/>
<path d="M255.32 260C255.32 260 256.5 254.67 271.67 255.17C286.84 255.67 288.33 260 289.5 261C290.67 262 290.83 263.67 288 264.5C288 264.5 266.43 266.93 258.33 263.33C254.42 261.59 255.32 260 255.32 260Z" fill="#935C0A" style="fill:#935C0A;fill:color(display-p3 0.5765 0.3608 0.0392);fill-opacity:1;"/>
<path d="M293.13 261.18C292.87 259.61 292.87 257.52 287.81 255.6C282.75 253.68 276.21 253.42 268.76 253.68C264.253 253.858 259.79 254.638 255.49 256C255.49 256 251.69 259.23 254.05 262.89C256.41 266.55 272.54 267.19 272.54 267.19C272.54 267.19 284.93 267.78 289.38 266.38C292.89 265.23 293.39 262.75 293.13 261.18ZM284.23 262.93C284.23 262.93 267.23 264.08 259.23 261.88C255.44 260.88 256.7 258.39 260.8 257.69C264.191 257.337 267.601 257.193 271.01 257.26C271.01 257.26 283.68 256.51 286.28 261.1C287.46 263.1 284.23 262.93 284.23 262.93Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M170 252.33C175 252.33 178.17 252.6 179.5 255.13C180.83 257.66 180.62 260.63 177.67 261.13C174.31 261.73 171.08 261.8 163.81 260.84C160.5 260.39 157.14 260.4 154.94 261.58C152.74 262.76 151.25 260.58 153.75 257.71C156.11 255 165 252.33 170 252.33Z" fill="#935C0A" style="fill:#935C0A;fill:color(display-p3 0.5765 0.3608 0.0392);fill-opacity:1;"/>
<path d="M183.62 255.13C183.62 255.13 182.38 250 171.62 249.88C160.86 249.76 150.22 255.13 150.22 255.13C150.22 255.13 145.72 258.5 147.35 261.75C148.98 265 154.85 264.08 154.85 264.08C157.643 263.474 160.484 263.12 163.34 263.02C166.19 263.1 173.59 263.88 173.59 263.88C176.466 263.962 179.299 263.175 181.72 261.62C182.288 261.337 182.792 260.939 183.199 260.452C183.606 259.965 183.909 259.399 184.087 258.79C184.265 258.181 184.316 257.541 184.236 256.911C184.155 256.281 183.946 255.675 183.62 255.13ZM176.38 259.62C173.585 260.282 170.675 260.282 167.88 259.62C163.5 258.5 157.88 259.5 155.5 260.12C153.12 260.74 152.62 258.75 153.62 258C154.62 257.25 158.62 254.12 169.25 254.12C169.25 254.12 176.88 254.04 178.12 256.38C179.68 259.28 176.38 259.62 176.38 259.62Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M113.14 360.08C113.14 360.08 97.1401 353.75 97.4701 341.08C97.4701 341.08 98.4701 329.42 106.81 328.42C106.81 328.42 86.1401 328.75 86.1401 314.75C86.1401 314.75 86.1401 302.08 97.4701 302.42C97.4701 302.42 78.4701 294.42 78.4701 280.75C78.4701 268.08 85.8101 259.75 95.8101 259.75C95.8101 259.75 80.4701 251.08 80.4701 239.08C80.4701 239.08 79.4701 228.75 85.1401 223.75C85.1401 223.75 76.1401 203.08 83.4701 186.42C83.4701 186.42 86.1401 175.75 104.14 172.75C104.14 172.75 98.1401 156.42 107.47 145.75C107.47 145.75 112.47 137.75 125.47 137.08C125.47 137.08 127.47 121.26 141.47 117.08C141.47 117.08 157.03 116.08 168.69 121.08C168.69 121.08 177.14 98.4301 195.14 91.7601C195.14 91.7601 214.34 84.7601 228.34 97.3701C228.34 97.3701 230.47 76.0901 247.97 76.0901C265.47 76.0901 272.66 91.1701 272.66 91.1701C272.66 91.1701 277.72 78.0401 294.47 83.0901C312.14 88.4301 312.47 112.43 312.47 112.43C312.47 112.43 326.47 97.7601 345.81 103.09C353.92 105.33 357.14 116.76 354.81 125.43C354.81 125.43 362.47 117.09 369.81 120.43C377.15 123.77 391.47 136.09 382.81 156.76C382.81 156.76 401.47 154.43 410.14 169.09C410.14 169.09 424.14 188.76 407.14 203.76C407.14 203.76 428.47 202.09 432.47 228.09C432.47 228.09 434.14 248.76 413.47 255.43C413.47 255.43 430.81 256.76 430.47 272.76C430.47 272.76 432.77 288.76 411.77 295.76C411.77 295.76 423.43 304.76 421.1 316.43C417.1 336.43 391.43 334.43 391.43 334.43C391.43 334.43 405.43 339.09 403.43 347.43C403.43 347.43 399.77 357.43 386.1 355.43C386.1 355.43 395.1 364.43 387.1 371.76C379.1 379.09 369.43 371.76 369.43 371.76C369.43 371.76 374.81 387.89 364.08 392.23C353.56 396.49 344.52 388.41 342.86 386.08C341.2 383.75 346.43 396.76 332.1 398.08C317.77 399.4 316.43 385.08 316.43 385.08C316.43 385.08 317.43 371.75 329.1 374.42C329.1 374.42 320.43 371.42 321.1 363.42C321.77 355.42 330.77 352.42 330.77 352.42C330.77 352.42 318.77 358.08 317.1 348.42C315.43 338.76 325.43 328.75 325.43 328.75C325.43 328.75 311.43 330.42 310.1 316.75C310.1 316.75 309.77 301.75 325.77 296.08C325.77 296.08 309.1 293.75 309.43 275.08C309.76 256.41 326.1 250.42 326.1 250.42C326.1 250.42 316.77 257.42 307.43 249.75C298.09 242.08 302.1 229.08 302.1 229.08C302.1 229.08 298.43 240.84 283.1 237.08C267.77 233.32 271.77 219.08 271.77 219.08C271.77 219.08 263.43 234.08 250.77 232.42C238.11 230.76 237.1 215.75 237.1 215.75C237.1 215.75 232.1 224.42 225.43 223.75C225.43 223.75 212.77 224.08 213.43 205.75C213.43 205.75 213.1 223.08 195.77 226.42C178.44 229.76 177.1 212.75 177.1 212.75C177.1 212.75 179.1 227.42 162.43 233.75C145.76 240.08 143.43 225.42 143.43 225.42C143.43 225.42 148.1 236.08 139.43 248.42C130.76 260.76 131.1 255.42 131.1 255.42C132.79 262.396 133.354 269.597 132.77 276.75C131.77 288.42 125.1 305.08 121.77 324.42C119.756 336.082 118.972 347.924 119.43 359.75L113.14 360.08Z" fill="#935C0A" style="fill:#935C0A;fill:color(display-p3 0.5765 0.3608 0.0392);fill-opacity:1;"/>
<path d="M331.19 358.25C335.93 356.62 335.62 353.81 333.31 352.19C331 350.57 327.13 351.56 326.19 352.94C325.25 354.32 323.05 354.05 321.75 351.81C317.63 344.7 324.31 335.12 328.69 333.69C334.69 331.69 332.38 328.25 332.38 328.25C332.01 327.648 331.462 327.174 330.812 326.895C330.162 326.616 329.442 326.544 328.75 326.69C326.94 327.12 326.12 327.88 324.75 327.88C320 327.88 315.75 323.5 315.88 317.44C316.01 311.38 321.56 302.31 326.81 301.5C332.06 300.69 331.28 297.95 331.28 297.95C331.28 297.95 331.91 295.88 326.81 294.81C317.34 292.81 314 289 313.63 277C313.26 265 318.75 257.56 324.44 256.21C333.68 254.02 332 248.31 331.38 247.62C330.828 247.254 330.156 247.115 329.503 247.232C328.851 247.349 328.27 247.714 327.88 248.25C327.19 249.5 324.1 254.15 315.04 251.08C300.68 246.23 306.94 231.08 307.94 228.38C308.94 225.68 304.88 225.94 304.88 225.94C304.88 225.94 300.5 225.62 299.81 227.62C298.94 230.17 294.44 238.81 285.69 237.06C274.85 234.9 274.81 224.94 275.12 221.56C275.43 218.18 274.81 217.88 273.12 217.88C271.43 217.88 269.69 217.81 269 220.69C266.43 231.44 251.94 231.88 251.94 231.88C238.44 231.56 239.18 215.15 239.75 210.88C240.44 205.75 240.44 205.79 239.49 205.88C238 205.97 236.38 209.28 236.38 211.41C236.481 213.484 235.981 215.543 234.94 217.34C233.75 219.34 231.25 223.65 226.31 223.47C221.37 223.29 218.75 218.47 218.69 213.59C218.63 209.34 218.88 204.59 218.88 204.59C218.81 203.28 218 201.72 216.69 202.28C215.38 202.84 214.19 203.41 212.31 204.41C211.77 204.729 211.339 205.203 211.071 205.77C210.804 206.337 210.713 206.971 210.81 207.59L211 210.97C211.06 214.47 207.89 218.66 205.62 220.72C198.75 226.97 191.7 226.91 188.27 223.41C184.84 219.91 184.19 218.34 183.27 214.47C182.74 212.28 181.27 211.72 180.46 212.03C179.65 212.34 176.71 213.65 176.71 213.65C176.437 213.798 176.198 214 176.007 214.244C175.816 214.489 175.678 214.77 175.601 215.071C175.523 215.371 175.509 215.685 175.559 215.991C175.609 216.297 175.722 216.589 175.89 216.85C176.62 218.29 174.82 221.85 174.08 222.91C171.94 226.13 165.4 232.47 160.02 233.36C154.43 234.29 151.35 228.36 149.85 225.36C148.35 222.36 145.02 223.53 142.02 225.36C139.02 227.19 139.85 229.36 139.85 229.36C141.85 235.64 139.64 243.91 137.52 247.7C132.19 257.21 125.52 254.7 123.52 254.15C121.52 253.6 120.02 255.7 122.69 258.86C125.36 262.02 132.1 260.61 132.1 260.61C132.1 260.61 126.85 291.2 122.19 308.7C117.53 326.2 114.52 348.36 114.52 359.7C114.52 371.04 116.69 401.86 145.35 418.7C174.01 435.54 199.52 432.53 208.35 431.03C208.35 431.03 221.02 428.52 226.19 425.53C226.19 425.53 228.98 423.65 229.19 427.53C229.52 433.78 228.5 440.53 223.85 447.2C223.85 447.2 219.19 454.63 241.52 454.63C250.19 454.63 255.69 453.54 265.85 446.03C265.85 446.03 267.85 445.36 267.35 440.36C266.85 435.36 265.68 429.86 266.43 426.36C266.43 426.36 304.08 418.59 322.07 400.44L322.45 393.6C316.79 385.23 326.14 378.78 327.21 379.34C328.28 379.9 328.96 380.03 331.21 379.78C333.46 379.53 333.71 378.41 333.27 377.03C332.83 375.65 330.96 374.15 327.4 372.72C323.84 371.29 327.38 359.56 331.19 358.25ZM321.08 359.19C317.65 361.62 317.33 368.33 320.4 371.52C321.34 372.52 321.28 373.52 320.4 373.77C311.92 376.03 310.87 385.54 315.81 393.85C317.46 396.62 316.56 397.27 316.56 397.27C300.31 413.02 278.19 419.5 267.62 421.56C264.52 422.17 262.38 423.44 261.15 428.1C259.63 433.86 260.23 441.27 260.23 441.27C248.23 451.27 231.31 448.69 233.31 446.19C237.122 441.425 237.512 432.936 238.25 427.38C239.44 418.44 232.82 413.82 222.53 417.5C200.6 425.33 174.98 425.25 159.81 418.44C137.55 408.44 127.42 393.23 125.31 371.94C123.2 350.65 123.92 343.5 131.31 307.86C138.7 272.22 136.56 257.15 136.56 257.15C147.19 253.15 148.5 236.25 148.5 236.25C162.81 246.88 180.75 223.88 180.75 223.88C188.81 235.31 206.06 231.14 213.88 219.31C215.25 227.88 232.88 232.44 237.25 218.75C235.19 235.06 255.94 243.25 269.5 227.88C270.18 227.1 270.82 227.5 271 228.44C273.65 242.5 288.73 245.35 297.4 240.44C299.99 238.96 299.94 240.5 299.94 240.5C298.86 251.24 313.88 255.5 317.09 255.2C317.9 255.14 318.09 255.75 317.69 255.98C311.59 259.98 308.39 266.54 307.46 273.37C306.13 283.17 310.63 293.04 316.88 296.14C320.16 297.76 318.23 299.14 318.23 299.14C310.9 305.06 308.56 312.93 308.48 316.73C308.4 320.53 310.81 330.14 317.81 330.98C320.3 331.27 318.98 332.89 318.98 332.89C313.13 340.33 313.65 354.56 319.48 357.23C322.07 358.37 322.21 358.39 321.08 359.19Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M116.48 365.51C97.26 364.19 83.38 349.08 93 331.67C80.38 327.62 75.25 312.25 85.25 304.12C73.12 298.62 63.62 277.5 84.62 257.38C72.24 249.38 73.75 232.38 82 224.5C74 218.25 72.88 178 98.75 173.5C92.88 154.12 111 134.24 122.25 137C124.88 122.76 144.25 106 165.62 118C167.92 119.29 168.51 119.54 169.5 116.25C172.25 107.12 198.5 75.01 223.12 92.88C227.5 95.38 227.93 94.88 228 91.25C228.25 78.88 256.53 64.5 270.75 86.25C272.88 89.5 273.12 89 275.25 86.38C277.38 83.76 303.12 67.25 315.75 105.88C339.12 93.12 359.25 103 359.25 120.38C378.12 113.5 394 135.75 390.12 155.62C413.5 155.88 428.5 179.25 417.88 203.12C442.88 213.25 447.5 245.5 422.5 255.12C442.88 265.75 438.92 290.48 420.12 299.88C418.62 300.62 419.03 301.19 419.5 301.5C425.5 305.5 434.85 336.44 404.5 338.12C400 338.38 400.38 339.88 401.75 341.12C403.12 342.36 415.12 357.12 394.5 359.75C401.38 375.12 384.5 382.5 377.38 381.25C379.25 389.75 366 400.88 348.62 395.5C346.38 404.73 324.88 404 319 398.5C317.873 397.546 316.965 396.36 316.34 395.022C315.715 393.684 315.388 392.227 315.38 390.75L320.63 388.6C320.63 388.6 320.89 396.84 329.73 396.73C338.73 396.61 340.54 392.98 340.02 387.73C339.75 384.98 341.75 385.6 341.75 385.6C341.75 385.6 344.62 386.1 345.75 384.86C346.88 383.62 348.25 383.86 348.75 385.1C349.25 386.34 352.88 394.1 362.62 389.48C372.36 384.86 368 378.25 367 376.38C366 374.51 366.12 372.38 367 372.12C368.605 372.008 370.109 371.294 371.21 370.12C372.6 368.48 373.97 372.4 375.28 373.83C376.41 375.04 385.28 374.56 385.28 364.92C385.28 358.16 381.78 355.04 381.78 355.04C381.78 355.04 399.65 354.54 399.91 348.16C400.17 341.78 393.51 340.76 391.28 340.16C390.227 339.903 389.312 339.254 388.721 338.345C388.13 337.437 387.908 336.337 388.099 335.271C388.29 334.204 388.881 333.25 389.75 332.603C390.62 331.957 391.703 331.666 392.78 331.79C396.89 332.18 417.15 331.17 417.15 314.79C417.15 303.29 410.28 302.16 410.28 302.16C405.53 300.79 407.03 292.36 411.96 292.44C418.53 292.54 426.91 283.66 426.65 272.92C426.65 272.92 428.2 255.68 412.03 258.79C405.53 260.04 406.78 255.92 409.15 253.79C411.52 251.66 437.69 235.58 424.91 218.54C415.53 206.04 407.86 209.54 407.86 209.54C403.53 210.93 400.57 203.54 404.91 200.3C410.15 196.43 421.53 164.93 389.28 162.43C387.15 162.26 386.34 162.95 385.65 164.43C384.96 165.91 382.91 165.15 382.78 163.75C382.68 162.63 382.25 162.39 381.27 162.75C380.29 163.11 376.78 162.22 380.65 156.35C385.49 149.02 378.91 124.88 366.15 124.6C360.53 124.48 355.49 129.43 353.15 129.69C351.5 129.88 349.62 128.89 350.78 122.1C351.53 117.72 348.56 105.18 331.28 107.1C324.53 107.85 318.94 109.25 315.53 114.1C313.9 116.41 312.46 116.96 310.15 116.85C306.97 116.7 305.41 114.1 305.91 108.35C306.41 102.6 299.66 87.48 287.91 87.48C282.15 87.48 275.91 92.1 272.03 96.35C268.28 90.72 258.61 74.77 241.53 82.48C231.25 87 230.62 97.62 230.75 99.75C230.88 101.88 228.5 102.88 226.12 101C223.74 99.12 198 76.5 174.88 118C170.74 125.43 169.48 127.56 163.88 123.88C159.5 121 133.34 114.49 129.75 137.38C128.75 143.75 127.25 144.38 119.15 144.13C113.28 143.95 100.88 155.75 108.88 176.75C88.38 181.12 83.56 188.75 83.88 204.25C84.12 216.75 85.6201 221.79 88.6201 224.12C88.6201 224.12 89.75 224.25 87.88 227.38C86.01 230.51 77.8201 247.91 102.12 259.12C103.75 259.88 104 263.38 101 263.38C98 263.38 81.88 269.25 82.12 282.25C82.36 295.25 94.0001 301 99.1201 301.25C104.24 301.5 102.5 306.88 99.25 306.75C96 306.62 90.2501 310.38 90.1201 316.12C89.9901 321.86 93 326.22 101.5 326.5C107.37 326.69 111.21 329.14 111.88 330.62C113 333.12 108 331.38 105.62 332.62C103.24 333.86 95.48 352.94 115.16 359.22L116.48 365.51Z" fill="#060500" style="fill:#060500;fill:color(display-p3 0.0235 0.0196 0.0000);fill-opacity:1;"/>
<path d="M266.69 341.67C283.43 341.67 297 332.268 297 320.67C297 309.072 283.43 299.67 266.69 299.67C249.95 299.67 236.38 309.072 236.38 320.67C236.38 332.268 249.95 341.67 266.69 341.67Z" fill="url(#paint0_radial_2705_62)" style=""/>
<path d="M365.31 267.69C360.31 267.44 353.69 268.25 350.69 271C351.75 261.75 348 261.18 345 261C342 260.82 342.25 263.79 342.25 263.79C342.25 263.79 349.5 274.54 346.25 315.79C349.238 314.474 351.828 312.396 353.758 309.763C355.689 307.13 356.893 304.036 357.25 300.79C357.25 300.79 362 298.34 365.5 298.44C369 298.54 380 295.54 379.75 284.04C379.5 272.54 370.31 267.94 365.31 267.69ZM360.56 293C357.06 294.69 357 292.81 357.06 290.88C357.12 288.95 357.06 284.25 355.69 282.69C355.295 282.349 355.038 281.875 354.968 281.357C354.898 280.839 355.019 280.314 355.31 279.88C355.31 279.88 369.69 272.56 370.12 279.38C370.55 286.2 364.06 291.31 360.56 293Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M355.31 279.88C355.02 280.314 354.898 280.839 354.968 281.357C355.038 281.875 355.295 282.349 355.69 282.69C357.06 284.25 357.12 288.94 357.06 290.88C357 292.82 357.06 294.69 360.56 293C364.06 291.31 370.56 286.19 370.12 279.38C369.68 272.57 355.31 279.88 355.31 279.88Z" fill="#C3915E" style="fill:#C3915E;fill:color(display-p3 0.7647 0.5686 0.3686);fill-opacity:1;"/>
<path d="M145.882 341.388C162.397 344.121 177.32 337.06 179.213 325.618C181.107 314.176 169.253 302.684 152.738 299.952C136.223 297.219 121.3 304.28 119.407 315.722C117.513 327.164 129.367 338.656 145.882 341.388Z" fill="url(#paint1_radial_2705_62)" style=""/>
<path d="M379.685 365.407C386.049 360.935 394.83 349.549 400.087 338.64C412.043 313.831 415.229 272.647 388.786 255.941C368.968 243.646 342.728 255.678 332.434 276.612C325.489 290.947 322.899 307.25 323.003 321.293C323.055 328.327 323.783 334.876 325.02 340.411C326.24 345.876 328.018 350.632 330.337 353.851L330.426 353.974L330.524 354.088C336.861 361.436 347.887 369.968 360.346 370.911C366.664 371.389 373.288 369.902 379.685 365.407Z" fill="#D9D9D9" style="fill:#D9D9D9;fill:color(display-p3 0.8510 0.8510 0.8510);fill-opacity:1;"/>
<path d="M382.5 360.496C392.208 348.839 398.5 339.496 402.5 327.996C378.022 347.989 341.487 308.372 330 291.996L325.5 319.996L329 345.496L337 358.496C345.988 364.371 351.219 367.275 362.5 368.496C370.394 367.93 374.778 366.33 382.5 360.496Z" fill="#C4C4C4" style="fill:#C4C4C4;fill:color(display-p3 0.7675 0.7675 0.7675);fill-opacity:1;"/>
<path d="M388.786 255.941C415.229 272.647 412.043 313.831 400.087 338.64C394.83 349.549 386.049 360.935 379.685 365.407C373.288 369.902 366.664 371.389 360.346 370.911C347.887 369.968 337 363.496 330.524 354.088L330.426 353.974L330.337 353.851C328.018 350.632 326.24 345.876 325.02 340.411C323.783 334.876 323.055 328.327 323.003 321.293C322.899 307.25 325.489 290.947 332.434 276.612C342.728 255.678 368.968 243.646 388.786 255.941ZM385.731 262.171C341.5 234.496 317.5 322.496 337 353.851C342.5 361.496 349.5 364.996 360 366.496C367.152 367.518 372.5 365.996 378.5 361.496C411.5 331.496 414.5 285.496 385.731 262.171Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<ellipse cx="375.924" cy="318.629" rx="23.6644" ry="34.6366" transform="rotate(15.2136 375.924 318.629)" fill="url(#paint2_linear_2705_62)" style=""/>
<path d="M384.882 285.688C390.982 287.347 395.574 292.482 398.026 299.55C400.476 306.615 400.764 315.563 398.277 324.708C395.79 333.853 391.01 341.422 385.319 346.273C379.626 351.126 373.065 353.228 366.966 351.569C360.866 349.91 356.274 344.775 353.823 337.708C351.372 330.642 351.085 321.695 353.572 312.55C356.059 303.405 360.838 295.836 366.529 290.985C372.222 286.132 378.782 284.03 384.882 285.688Z" stroke="black" stroke-opacity="0.04" style="stroke:black;stroke-opacity:0.04;"/>
<path d="M386.5 275.496C382.693 281.016 375.137 280.483 375.5 275.496C377.5 247.996 371.854 220.791 367.5 201.496C366.239 195.906 373.001 192.104 379.5 201.496C388.613 214.666 392.891 266.23 386.5 275.496Z" fill="#CCCCCC" style="fill:#CCCCCC;fill:color(display-p3 0.8000 0.8000 0.8000);fill-opacity:1;"/>
<path d="M370 194.496C371.5 193.996 374 193.496 376.231 195.485C396.517 216.295 392.652 266.406 388.482 275.727C387.431 279.866 383.968 282.223 380.862 281.996C379.31 281.883 377.763 281.337 376.596 280.36C375.4 279.358 373.873 277.186 374.003 275.388L374.176 272.821C375.777 246.725 372.876 220.547 366.418 202.889C365.105 199.359 366.588 196.202 370 194.496ZM375 197.996C373.834 196.697 372.21 195.901 370.5 196.496C367.68 197.477 367.5 200.496 368.5 202.496C374.752 212.273 378.559 239.266 377.169 273.013L376.996 275.605C376.945 276.3 377.222 276.883 377.796 277.364C378.4 277.87 379.317 278.232 380.354 278.308C382.426 278.459 384.447 277.487 385.046 275.127C388.158 268.255 392.607 217.609 375 197.996Z" fill="black" style="fill:black;fill-opacity:1;"/>
<path d="M373.5 191.996L370.5 207.996L367.5 211.996L364 210.496L359 195.996L368.5 183.996L373.5 191.996Z" fill="#935C0A" style="fill:#935C0A;fill:color(display-p3 0.5765 0.3608 0.0392);fill-opacity:1;"/>
<path d="M334.877 174.978C334.304 174.204 334.419 173.109 335.216 172.57C343.088 167.243 356.425 169.18 355.184 180.576C376.636 175.278 380.779 199.596 370.874 211.055C370.364 211.646 369.53 211.815 368.802 211.534V211.534C367.326 210.964 367.022 208.863 367.95 207.582C374.451 198.604 371.377 178.93 352.916 187.056L351.571 187.981C349.945 189.098 347.855 187.437 348.576 185.602L349.173 184.082C352.702 172.687 345.867 169.813 337.532 175.388C336.671 175.964 335.493 175.811 334.877 174.978V174.978Z" fill="black" style="fill:black;fill-opacity:1;"/>
</g>
<defs>
<radialGradient id="paint0_radial_2705_62" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(267.775 321.564) scale(28.341 22.842)">
<stop stop-color="#E86A58" stop-opacity="0.18" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.18;"/>
<stop offset="0.3" stop-color="#E86A58" stop-opacity="0.16" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.16;"/>
<stop offset="0.63" stop-color="#E86A58" stop-opacity="0.1" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.1;"/>
<stop offset="0.99" stop-color="#E86A58" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
<stop offset="1" stop-color="#E86A58" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<radialGradient id="paint1_radial_2705_62" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(149.715 321.643) rotate(9.39525) scale(28.341 22.842)">
<stop stop-color="#E86A58" stop-opacity="0.18" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.18;"/>
<stop offset="0.3" stop-color="#E86A58" stop-opacity="0.16" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.16;"/>
<stop offset="0.63" stop-color="#E86A58" stop-opacity="0.1" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.1;"/>
<stop offset="0.99" stop-color="#E86A58" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
<stop offset="1" stop-color="#E86A58" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<linearGradient id="paint2_linear_2705_62" x1="375.924" y1="283.992" x2="382.735" y2="352.91" gradientUnits="userSpaceOnUse">
<stop stop-color="#BABABA" style="stop-color:#BABABA;stop-color:color(display-p3 0.7275 0.7275 0.7275);stop-opacity:1;"/>
<stop offset="0.829668" stop-color="#B3B3B3" style="stop-color:#B3B3B3;stop-color:color(display-p3 0.7020 0.7020 0.7020);stop-opacity:1;"/>
</linearGradient>
<clipPath id="clip0_2705_62">
<rect width="497" height="497" fill="white" style="fill:white;fill-opacity:1;"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,59 @@
<svg width="497" height="497" viewBox="0 0 497 497" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2704_2)">
<path d="M497 0H0V497H497V0Z" fill="#FF2602" style="fill:#FF2602;fill:color(display-p3 1.0000 0.1490 0.0078);fill-opacity:1;"/>
<path d="M155.92 497C155.92 497 171.19 448 215.69 431C234.91 423.66 269.19 421.5 291.92 433.5C305.42 440.63 312.92 453.5 316.92 461.73C322.374 472.659 324.956 484.797 324.42 497H155.92Z" fill="#BCBCBC" style="fill:#BCBCBC;fill:color(display-p3 0.7373 0.7373 0.7373);fill-opacity:1;"/>
<path d="M264.33 424.17C292.5 424.17 327.21 445.17 331.83 485.17C332.98 495.06 333.12 497.88 333.12 497.88H322.5C322.5 497.88 325.65 436.08 265 426.83C245.92 423.92 179.5 437.83 158.33 497.88H145.83C145.83 497.88 157 442 226.5 419.5C251.06 412.94 264.33 424.17 264.33 424.17Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M296 497.88C296 497.88 295.5 486.62 296.75 481.5C298 476.38 295.88 473.5 293.75 473.12C291.62 472.74 288.88 473.88 287.88 478.25C286.657 484.722 286.028 491.293 286 497.88H296Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M131.81 207.25C131.81 207.25 137.14 236.25 136.81 250.08C136.81 250.08 134.48 274.91 129.81 293.25C125.14 311.59 120.81 348.91 126.14 365.25C131.47 381.59 153.48 411.25 194.48 405.25C235.48 399.25 237.14 397.58 236.81 407.58C236.48 417.58 235.48 427.58 231.48 433.91C231.48 433.91 244.81 436.25 251.48 431.58L258.14 426.91C258.14 426.91 258.54 415.27 261.48 408.25C263.48 403.45 266.14 403.25 266.14 403.25C266.14 403.25 300.81 394.91 320.14 377.58C335.67 363.66 351.48 337.25 354.48 299.91C354.48 299.91 366.81 294.25 368.48 288.25L370.14 282.25C370.14 282.25 370.48 275.91 364.81 276.91C359.14 277.91 353.81 281.91 353.81 281.91C353.81 281.91 360.81 223.25 352.14 193.91C347.14 176.98 338.36 158.64 312.14 141.25C276.48 117.58 189.48 120.25 142.81 157.91C125.16 172.16 131.81 207.25 131.81 207.25Z" fill="#C3915E" style="fill:#C3915E;fill:color(display-p3 0.7647 0.5686 0.3686);fill-opacity:1;"/>
<path d="M135.85 261C135.85 261 147.71 253.65 150.67 246.34C162.35 252.34 181.57 252.34 184.29 246.34C187.01 240.34 184.38 235.16 180.66 232.91C184 234.1 186.25 232.1 186.66 229.54C190.35 232.54 208.22 230.42 212.53 223.16C215.72 226.66 232.41 225.44 237.35 218.85C241.47 225.66 266.28 226.79 274.35 217.16C274.35 217.16 290.69 204.05 277.2 178.33C263.71 152.61 151 155.67 135.85 183.13C120.7 210.59 118.08 236.68 135.85 261Z" fill="#AD723B" style="fill:#AD723B;fill:color(display-p3 0.6784 0.4471 0.2314);fill-opacity:1;"/>
<path d="M245.54 433.73C245.54 433.73 243.72 417 254 405.68C265 393.58 289 392.08 302.7 367.88C302.7 367.88 298.2 354.62 307.2 342.62C307.2 342.62 295.01 321 320.2 310.62L355.07 304.09C355.07 304.09 353.37 331.17 337.2 356.38C331.04 367.44 317.2 387.67 274.83 401.12C274.83 401.12 262.33 404.38 261.04 411.4C260.212 415.793 260.104 420.292 260.72 424.72C260.778 425.106 260.737 425.5 260.6 425.866C260.464 426.232 260.237 426.557 259.94 426.81C255.829 430.338 250.863 432.724 245.54 433.73Z" fill="#AD723B" style="fill:#AD723B;fill:color(display-p3 0.6784 0.4471 0.2314);fill-opacity:1;"/>
<path d="M178.25 359.94C198 360.69 231.62 355.44 231.62 355.44C231.62 355.44 232 344.88 235.5 341C239 337.12 241.12 339.12 241.12 339.12C241.12 339.12 244 340.5 241.12 345.12C238.24 349.74 238.56 356.38 239.31 359.56C240.06 362.74 245 370.5 245 370.5C245.033 370.662 245.02 370.83 244.964 370.986C244.908 371.141 244.809 371.278 244.68 371.381C244.551 371.485 244.396 371.551 244.232 371.571C244.068 371.592 243.901 371.568 243.75 371.5C242.38 371.12 241.12 372.75 241.12 372.75C241.12 372.75 240.38 373.61 238.88 371.88C236.122 368.537 233.945 364.754 232.44 360.69C232.44 360.69 203.86 366.31 177.92 365.31C177.92 365.31 175.81 365.69 175.06 362.56C174.35 359.6 178.25 359.94 178.25 359.94Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M216.12 280C216.12 277.37 216.28 276 218.72 275.87C221.16 275.74 221.06 278.75 221.06 278.75C221.06 278.75 220.67 293.91 215.06 307.75C215.06 307.75 212.19 315.19 210.12 315.19C209.084 315.438 207.994 315.317 207.038 314.848C206.081 314.379 205.318 313.591 204.88 312.62C204.88 312.62 203.56 309.07 202.16 308.97C199.59 308.78 197.96 313.58 197.6 317.23C197.38 319.5 196.44 320.94 194.12 320.94C191.8 320.94 190.94 319.55 191.19 317.12C192.19 310.94 196.47 303.91 199.44 300.94C199.728 300.646 200.072 300.411 200.451 300.25C200.831 300.088 201.238 300.004 201.65 300C204.75 300 205.65 302.67 206.07 304.89C206.81 308.75 208.12 308.47 208.12 308.47C208.12 308.47 208.7 308.67 209.5 307.06C212.94 300.12 216.12 288.44 216.12 280Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M267.4 265.57C267.49 263.69 268.84 260.57 271.88 261C274.25 261.31 275.69 263 275.31 266.5C274.88 270.5 271.47 287.69 268.86 292.25C267.37 294.86 266 295.44 263.86 294.96C262.86 294.73 261.73 293.44 262.28 290.06C263 285.59 266.77 277.57 267.4 265.57Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M168.91 258.88C168.91 258.88 170.91 258.81 171.29 261.53C172.1 266.64 171.55 276.73 169.64 285.1C169.64 285.1 168.27 288.92 165.46 288.92C162.65 288.92 162.29 285.75 162.36 283.66C162.43 281.57 165.1 275.59 165.1 264.86C165.1 258.08 168.91 258.88 168.91 258.88Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M253.28 242.12C253.22 248.12 259.28 252.5 269.88 252.75C280.48 253 285.55 251.58 290.88 246.67C296.21 241.76 280.36 237.94 271.04 238.26C261.72 238.58 253.31 239 253.28 242.12Z" fill="#AD723B" style="fill:#AD723B;fill:color(display-p3 0.6784 0.4471 0.2314);fill-opacity:1;"/>
<path d="M255.32 241C255.32 241 256.5 235.67 271.67 236.17C286.84 236.67 288.33 241 289.5 242C290.67 243 290.83 244.67 288 245.5C288 245.5 266.43 247.93 258.33 244.33C254.42 242.59 255.32 241 255.32 241Z" fill="#935C0A" style="fill:#935C0A;fill:color(display-p3 0.5765 0.3608 0.0392);fill-opacity:1;"/>
<path d="M293.13 242.18C292.87 240.61 292.87 238.52 287.81 236.6C282.75 234.68 276.21 234.42 268.76 234.68C264.253 234.858 259.79 235.638 255.49 237C255.49 237 251.69 240.23 254.05 243.89C256.41 247.55 272.54 248.19 272.54 248.19C272.54 248.19 284.93 248.78 289.38 247.38C292.89 246.23 293.39 243.75 293.13 242.18ZM284.23 243.93C284.23 243.93 267.23 245.08 259.23 242.88C255.44 241.88 256.7 239.39 260.8 238.69C264.191 238.337 267.601 238.193 271.01 238.26C271.01 238.26 283.68 237.51 286.28 242.1C287.46 244.1 284.23 243.93 284.23 243.93Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M170 233.33C175 233.33 178.17 233.6 179.5 236.13C180.83 238.66 180.62 241.63 177.67 242.13C174.31 242.73 171.08 242.8 163.81 241.84C160.5 241.39 157.14 241.4 154.94 242.58C152.74 243.76 151.25 241.58 153.75 238.71C156.11 236 165 233.33 170 233.33Z" fill="#935C0A" style="fill:#935C0A;fill:color(display-p3 0.5765 0.3608 0.0392);fill-opacity:1;"/>
<path d="M183.62 236.13C183.62 236.13 182.38 231 171.62 230.88C160.86 230.76 150.22 236.13 150.22 236.13C150.22 236.13 145.72 239.5 147.35 242.75C148.98 246 154.85 245.08 154.85 245.08C157.643 244.474 160.484 244.12 163.34 244.02C166.19 244.1 173.59 244.88 173.59 244.88C176.466 244.962 179.299 244.175 181.72 242.62C182.288 242.337 182.792 241.939 183.199 241.452C183.606 240.965 183.909 240.399 184.087 239.79C184.265 239.181 184.316 238.541 184.236 237.911C184.155 237.281 183.946 236.675 183.62 236.13ZM176.38 240.62C173.585 241.282 170.675 241.282 167.88 240.62C163.5 239.5 157.88 240.5 155.5 241.12C153.12 241.74 152.62 239.75 153.62 239C154.62 238.25 158.62 235.12 169.25 235.12C169.25 235.12 176.88 235.04 178.12 237.38C179.68 240.28 176.38 240.62 176.38 240.62Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M113.14 341.08C113.14 341.08 97.1401 334.75 97.4701 322.08C97.4701 322.08 98.4701 310.42 106.81 309.42C106.81 309.42 86.1401 309.75 86.1401 295.75C86.1401 295.75 86.1401 283.08 97.4701 283.42C97.4701 283.42 78.4701 275.42 78.4701 261.75C78.4701 249.08 85.8101 240.75 95.8101 240.75C95.8101 240.75 80.4701 232.08 80.4701 220.08C80.4701 220.08 79.4701 209.75 85.1401 204.75C85.1401 204.75 76.1401 184.08 83.4701 167.42C83.4701 167.42 86.1401 156.75 104.14 153.75C104.14 153.75 98.1401 137.42 107.47 126.75C107.47 126.75 112.47 118.75 125.47 118.08C125.47 118.08 127.47 102.26 141.47 98.0801C141.47 98.0801 157.03 97.0801 168.69 102.08C168.69 102.08 177.14 79.4301 195.14 72.7601C195.14 72.7601 214.34 65.7601 228.34 78.3701C228.34 78.3701 230.47 57.0901 247.97 57.0901C265.47 57.0901 272.66 72.1701 272.66 72.1701C272.66 72.1701 277.72 59.0401 294.47 64.0901C312.14 69.4301 312.47 93.4301 312.47 93.4301C312.47 93.4301 326.47 78.7601 345.81 84.0901C353.92 86.3301 357.14 97.7601 354.81 106.43C354.81 106.43 362.47 98.0901 369.81 101.43C377.15 104.77 391.47 117.09 382.81 137.76C382.81 137.76 401.47 135.43 410.14 150.09C410.14 150.09 424.14 169.76 407.14 184.76C407.14 184.76 428.47 183.09 432.47 209.09C432.47 209.09 434.14 229.76 413.47 236.43C413.47 236.43 430.81 237.76 430.47 253.76C430.47 253.76 432.77 269.76 411.77 276.76C411.77 276.76 423.43 285.76 421.1 297.43C417.1 317.43 391.43 315.43 391.43 315.43C391.43 315.43 405.43 320.09 403.43 328.43C403.43 328.43 399.77 338.43 386.1 336.43C386.1 336.43 395.1 345.43 387.1 352.76C379.1 360.09 369.43 352.76 369.43 352.76C369.43 352.76 374.81 368.89 364.08 373.23C353.56 377.49 344.52 369.41 342.86 367.08C341.2 364.75 346.43 377.76 332.1 379.08C317.77 380.4 316.43 366.08 316.43 366.08C316.43 366.08 317.43 352.75 329.1 355.42C329.1 355.42 320.43 352.42 321.1 344.42C321.77 336.42 330.77 333.42 330.77 333.42C330.77 333.42 318.77 339.08 317.1 329.42C315.43 319.76 325.43 309.75 325.43 309.75C325.43 309.75 311.43 311.42 310.1 297.75C310.1 297.75 309.77 282.75 325.77 277.08C325.77 277.08 309.1 274.75 309.43 256.08C309.76 237.41 326.1 231.42 326.1 231.42C326.1 231.42 316.77 238.42 307.43 230.75C298.09 223.08 302.1 210.08 302.1 210.08C302.1 210.08 298.43 221.84 283.1 218.08C267.77 214.32 271.77 200.08 271.77 200.08C271.77 200.08 263.43 215.08 250.77 213.42C238.11 211.76 237.1 196.75 237.1 196.75C237.1 196.75 232.1 205.42 225.43 204.75C225.43 204.75 212.77 205.08 213.43 186.75C213.43 186.75 213.1 204.08 195.77 207.42C178.44 210.76 177.1 193.75 177.1 193.75C177.1 193.75 179.1 208.42 162.43 214.75C145.76 221.08 143.43 206.42 143.43 206.42C143.43 206.42 148.1 217.08 139.43 229.42C130.76 241.76 131.1 236.42 131.1 236.42C132.79 243.396 133.354 250.597 132.77 257.75C131.77 269.42 125.1 286.08 121.77 305.42C119.756 317.082 118.972 328.924 119.43 340.75L113.14 341.08Z" fill="#935C0A" style="fill:#935C0A;fill:color(display-p3 0.5765 0.3608 0.0392);fill-opacity:1;"/>
<path d="M331.19 339.25C335.93 337.62 335.62 334.81 333.31 333.19C331 331.57 327.13 332.56 326.19 333.94C325.25 335.32 323.05 335.05 321.75 332.81C317.63 325.7 324.31 316.12 328.69 314.69C334.69 312.69 332.38 309.25 332.38 309.25C332.01 308.648 331.462 308.174 330.812 307.895C330.162 307.616 329.442 307.544 328.75 307.69C326.94 308.12 326.12 308.88 324.75 308.88C320 308.88 315.75 304.5 315.88 298.44C316.01 292.38 321.56 283.31 326.81 282.5C332.06 281.69 331.28 278.95 331.28 278.95C331.28 278.95 331.91 276.88 326.81 275.81C317.34 273.81 314 270 313.63 258C313.26 246 318.75 238.56 324.44 237.21C333.68 235.02 332 229.31 331.38 228.62C330.828 228.254 330.156 228.115 329.503 228.232C328.851 228.349 328.27 228.714 327.88 229.25C327.19 230.5 324.1 235.15 315.04 232.08C300.68 227.23 306.94 212.08 307.94 209.38C308.94 206.68 304.88 206.94 304.88 206.94C304.88 206.94 300.5 206.62 299.81 208.62C298.94 211.17 294.44 219.81 285.69 218.06C274.85 215.9 274.81 205.94 275.12 202.56C275.43 199.18 274.81 198.88 273.12 198.88C271.43 198.88 269.69 198.81 269 201.69C266.43 212.44 251.94 212.88 251.94 212.88C238.44 212.56 239.18 196.15 239.75 191.88C240.44 186.75 240.44 186.79 239.49 186.88C238 186.97 236.38 190.28 236.38 192.41C236.481 194.484 235.981 196.543 234.94 198.34C233.75 200.34 231.25 204.65 226.31 204.47C221.37 204.29 218.75 199.47 218.69 194.59C218.63 190.34 218.88 185.59 218.88 185.59C218.81 184.28 218 182.72 216.69 183.28C215.38 183.84 214.19 184.41 212.31 185.41C211.77 185.729 211.339 186.203 211.071 186.77C210.804 187.337 210.713 187.971 210.81 188.59L211 191.97C211.06 195.47 207.89 199.66 205.62 201.72C198.75 207.97 191.7 207.91 188.27 204.41C184.84 200.91 184.19 199.34 183.27 195.47C182.74 193.28 181.27 192.72 180.46 193.03C179.65 193.34 176.71 194.65 176.71 194.65C176.437 194.798 176.198 195 176.007 195.244C175.816 195.489 175.678 195.77 175.601 196.071C175.523 196.371 175.509 196.685 175.559 196.991C175.609 197.297 175.722 197.589 175.89 197.85C176.62 199.29 174.82 202.85 174.08 203.91C171.94 207.13 165.4 213.47 160.02 214.36C154.43 215.29 151.35 209.36 149.85 206.36C148.35 203.36 145.02 204.53 142.02 206.36C139.02 208.19 139.85 210.36 139.85 210.36C141.85 216.64 139.64 224.91 137.52 228.7C132.19 238.21 125.52 235.7 123.52 235.15C121.52 234.6 120.02 236.7 122.69 239.86C125.36 243.02 132.1 241.61 132.1 241.61C132.1 241.61 126.85 272.2 122.19 289.7C117.53 307.2 114.52 329.36 114.52 340.7C114.52 352.04 116.69 382.86 145.35 399.7C174.01 416.54 199.52 413.53 208.35 412.03C208.35 412.03 221.02 409.52 226.19 406.53C226.19 406.53 228.98 404.65 229.19 408.53C229.52 414.78 227.5 426.53 222.85 433.2C222.85 433.2 218.19 440.63 240.52 440.63C249.19 440.63 254.69 439.54 264.85 432.03C264.85 432.03 266.85 431.36 266.35 426.36C265.85 421.36 265.68 410.86 266.43 407.36C266.43 407.36 304.08 399.59 322.07 381.44L322.45 374.6C316.79 366.23 326.14 359.78 327.21 360.34C328.28 360.9 328.96 361.03 331.21 360.78C333.46 360.53 333.71 359.41 333.27 358.03C332.83 356.65 330.96 355.15 327.4 353.72C323.84 352.29 327.38 340.56 331.19 339.25ZM321.08 340.19C317.65 342.62 317.33 349.33 320.4 352.52C321.34 353.52 321.28 354.52 320.4 354.77C311.92 357.03 310.87 366.54 315.81 374.85C317.46 377.62 316.56 378.27 316.56 378.27C300.31 394.02 278.19 400.5 267.62 402.56C264.52 403.17 262.38 404.44 261.15 409.1C259.63 414.86 259.23 427.27 259.23 427.27C247.23 437.27 230.31 434.69 232.31 432.19C234.31 429.69 235.31 424.93 235.31 424.93C235.31 424.93 236.54 421.26 238.25 408.38C239.44 399.44 232.82 394.82 222.53 398.5C200.6 406.33 174.98 406.25 159.81 399.44C137.55 389.44 127.42 374.23 125.31 352.94C123.2 331.65 123.92 324.5 131.31 288.86C138.7 253.22 136.56 238.15 136.56 238.15C147.19 234.15 148.5 217.25 148.5 217.25C162.81 227.88 180.75 204.88 180.75 204.88C188.81 216.31 206.06 212.14 213.88 200.31C215.25 208.88 232.88 213.44 237.25 199.75C235.19 216.06 255.94 224.25 269.5 208.88C270.18 208.1 270.82 208.5 271 209.44C273.65 223.5 288.73 226.35 297.4 221.44C299.99 219.96 299.94 221.5 299.94 221.5C298.86 232.24 313.88 236.5 317.09 236.2C317.9 236.14 318.09 236.75 317.69 236.98C311.59 240.98 308.39 247.54 307.46 254.37C306.13 264.17 310.63 274.04 316.88 277.14C320.16 278.76 318.23 280.14 318.23 280.14C310.9 286.06 308.56 293.93 308.48 297.73C308.4 301.53 310.81 311.14 317.81 311.98C320.3 312.27 318.98 313.89 318.98 313.89C313.13 321.33 313.65 335.56 319.48 338.23C322.07 339.37 322.21 339.39 321.08 340.19Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M116.48 346.51C97.26 345.19 83.38 330.08 93 312.67C80.38 308.62 75.25 293.25 85.25 285.12C73.12 279.62 63.62 258.5 84.62 238.38C72.24 230.38 73.75 213.38 82 205.5C74 199.25 72.88 159 98.75 154.5C92.88 135.12 111 115.24 122.25 118C124.88 103.76 144.25 87 165.62 99C167.92 100.29 168.51 100.54 169.5 97.25C172.25 88.12 198.5 56.01 223.12 73.88C227.5 76.38 227.93 75.88 228 72.25C228.25 59.88 256.53 45.5 270.75 67.25C272.88 70.5 273.12 70 275.25 67.38C277.38 64.76 303.12 48.25 315.75 86.88C339.12 74.12 359.25 84 359.25 101.38C378.12 94.5 394 116.75 390.12 136.62C413.5 136.88 428.5 160.25 417.88 184.12C442.88 194.25 447.5 226.5 422.5 236.12C442.88 246.75 438.92 271.48 420.12 280.88C418.62 281.62 419.03 282.19 419.5 282.5C425.5 286.5 434.85 317.44 404.5 319.12C400 319.38 400.38 320.88 401.75 322.12C403.12 323.36 415.12 338.12 394.5 340.75C401.38 356.12 384.5 363.5 377.38 362.25C379.25 370.75 366 381.88 348.62 376.5C346.38 385.73 324.88 385 319 379.5C317.873 378.546 316.965 377.36 316.34 376.022C315.715 374.684 315.388 373.227 315.38 371.75L320.63 369.6C320.63 369.6 320.89 377.84 329.73 377.73C338.73 377.61 340.54 373.98 340.02 368.73C339.75 365.98 341.75 366.6 341.75 366.6C341.75 366.6 344.62 367.1 345.75 365.86C346.88 364.62 348.25 364.86 348.75 366.1C349.25 367.34 352.88 375.1 362.62 370.48C372.36 365.86 368 359.25 367 357.38C366 355.51 366.12 353.38 367 353.12C368.605 353.008 370.109 352.294 371.21 351.12C372.6 349.48 373.97 353.4 375.28 354.83C376.41 356.04 385.28 355.56 385.28 345.92C385.28 339.16 381.78 336.04 381.78 336.04C381.78 336.04 399.65 335.54 399.91 329.16C400.17 322.78 393.51 321.76 391.28 321.16C390.227 320.903 389.312 320.254 388.721 319.345C388.13 318.437 387.908 317.337 388.099 316.271C388.29 315.204 388.881 314.25 389.75 313.603C390.62 312.957 391.703 312.666 392.78 312.79C396.89 313.18 417.15 312.17 417.15 295.79C417.15 284.29 410.28 283.16 410.28 283.16C405.53 281.79 407.03 273.36 411.96 273.44C418.53 273.54 426.91 264.66 426.65 253.92C426.65 253.92 428.2 236.68 412.03 239.79C405.53 241.04 406.78 236.92 409.15 234.79C411.52 232.66 437.69 216.58 424.91 199.54C415.53 187.04 407.86 190.54 407.86 190.54C403.53 191.93 400.57 184.54 404.91 181.3C410.15 177.43 421.53 145.93 389.28 143.43C387.15 143.26 386.34 143.95 385.65 145.43C384.96 146.91 382.91 146.15 382.78 144.75C382.68 143.63 382.25 143.39 381.27 143.75C380.29 144.11 376.78 143.22 380.65 137.35C385.49 130.02 378.91 105.88 366.15 105.6C360.53 105.48 355.49 110.43 353.15 110.69C351.5 110.88 349.62 109.89 350.78 103.1C351.53 98.72 348.56 86.18 331.28 88.1C324.53 88.85 318.94 90.25 315.53 95.1C313.9 97.41 312.46 97.96 310.15 97.85C306.97 97.7 305.41 95.1 305.91 89.35C306.41 83.6 299.66 68.48 287.91 68.48C282.15 68.48 275.91 73.1 272.03 77.35C268.28 71.72 258.61 55.77 241.53 63.48C231.25 68 230.62 78.62 230.75 80.75C230.88 82.88 228.5 83.88 226.12 82C223.74 80.12 198 57.5 174.88 99C170.74 106.43 169.48 108.56 163.88 104.88C159.5 102 133.34 95.49 129.75 118.38C128.75 124.75 127.25 125.38 119.15 125.13C113.28 124.95 100.88 136.75 108.88 157.75C88.38 162.12 83.56 169.75 83.88 185.25C84.12 197.75 85.6201 202.79 88.6201 205.12C88.6201 205.12 89.75 205.25 87.88 208.38C86.01 211.51 77.8201 228.91 102.12 240.12C103.75 240.88 104 244.38 101 244.38C98 244.38 81.88 250.25 82.12 263.25C82.36 276.25 94 282 99.1201 282.25C104.24 282.5 102.5 287.88 99.25 287.75C96 287.62 90.25 291.38 90.1201 297.12C89.9901 302.86 93 307.22 101.5 307.5C107.37 307.69 111.21 310.14 111.88 311.62C113 314.12 108 312.38 105.62 313.62C103.24 314.86 95.48 333.94 115.16 340.22L116.48 346.51Z" fill="#060500" style="fill:#060500;fill:color(display-p3 0.0235 0.0196 0.0000);fill-opacity:1;"/>
<path d="M266.69 341.67C283.43 341.67 297 332.268 297 320.67C297 309.072 283.43 299.67 266.69 299.67C249.95 299.67 236.38 309.072 236.38 320.67C236.38 332.268 249.95 341.67 266.69 341.67Z" fill="url(#paint0_radial_2704_2)" style=""/>
<path d="M365.31 267.69C360.31 267.44 353.69 268.25 350.69 271C351.75 261.75 348 261.18 345 261C342 260.82 342.25 263.79 342.25 263.79C342.25 263.79 349.5 274.54 346.25 315.79C349.238 314.474 351.828 312.396 353.758 309.763C355.689 307.13 356.893 304.036 357.25 300.79C357.25 300.79 362 298.34 365.5 298.44C369 298.54 380 295.54 379.75 284.04C379.5 272.54 370.31 267.94 365.31 267.69ZM360.56 293C357.06 294.69 357 292.81 357.06 290.88C357.12 288.95 357.06 284.25 355.69 282.69C355.295 282.349 355.038 281.875 354.968 281.357C354.898 280.839 355.019 280.314 355.31 279.88C355.31 279.88 369.69 272.56 370.12 279.38C370.55 286.2 364.06 291.31 360.56 293Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<path d="M355.31 279.88C355.02 280.314 354.898 280.839 354.968 281.357C355.038 281.875 355.295 282.349 355.69 282.69C357.06 284.25 357.12 288.94 357.06 290.88C357 292.82 357.06 294.69 360.56 293C364.06 291.31 370.56 286.19 370.12 279.38C369.68 272.57 355.31 279.88 355.31 279.88Z" fill="#C3915E" style="fill:#C3915E;fill:color(display-p3 0.7647 0.5686 0.3686);fill-opacity:1;"/>
<path d="M145.882 341.388C162.397 344.121 177.32 337.06 179.213 325.618C181.107 314.176 169.253 302.684 152.738 299.952C136.223 297.219 121.3 304.28 119.407 315.722C117.513 327.164 129.367 338.656 145.882 341.388Z" fill="url(#paint1_radial_2704_2)" style=""/>
<path d="M379.685 347.407C386.049 342.935 394.83 331.549 400.087 320.64C412.043 295.831 415.229 254.647 388.786 237.941C368.968 225.646 342.728 237.678 332.434 258.612C325.489 272.947 322.899 289.25 323.003 303.293C323.055 310.327 323.783 316.876 325.02 322.411C326.24 327.876 328.018 332.632 330.337 335.851L330.426 335.974L330.524 336.088C336.861 343.436 347.887 351.968 360.346 352.911C366.664 353.389 373.288 351.902 379.685 347.407Z" fill="#D9D9D9" style="fill:#D9D9D9;fill:color(display-p3 0.8510 0.8510 0.8510);fill-opacity:1;"/>
<path d="M382.5 342.496C392.208 330.839 398.5 321.496 402.5 309.996C378.022 329.989 341.487 290.372 330 273.996L325.5 301.996L329 327.496L337 340.496C345.988 346.371 351.219 349.275 362.5 350.496C370.394 349.93 374.778 348.33 382.5 342.496Z" fill="#C4C4C4" style="fill:#C4C4C4;fill:color(display-p3 0.7675 0.7675 0.7675);fill-opacity:1;"/>
<path d="M388.786 237.941C415.229 254.647 412.043 295.831 400.087 320.64C394.83 331.549 386.049 342.935 379.685 347.407C373.288 351.902 366.664 353.389 360.346 352.911C347.887 351.968 337 345.496 330.524 336.088L330.426 335.974L330.337 335.851C328.018 332.632 326.24 327.876 325.02 322.411C323.783 316.876 323.055 310.327 323.003 303.293C322.899 289.25 325.489 272.947 332.434 258.612C342.728 237.678 368.968 225.646 388.786 237.941ZM385.731 244.171C341.5 216.496 317.5 304.496 337 335.851C342.5 343.496 349.5 346.996 360 348.496C367.152 349.518 372.5 347.996 378.5 343.496C411.5 313.496 414.5 267.496 385.731 244.171Z" fill="#070610" style="fill:#070610;fill:color(display-p3 0.0275 0.0235 0.0627);fill-opacity:1;"/>
<ellipse cx="375.924" cy="300.629" rx="23.6644" ry="34.6366" transform="rotate(15.2136 375.924 300.629)" fill="url(#paint2_linear_2704_2)" style=""/>
<path d="M384.882 267.688C390.982 269.347 395.574 274.482 398.026 281.55C400.476 288.615 400.764 297.563 398.277 306.708C395.79 315.853 391.01 323.422 385.319 328.273C379.626 333.126 373.065 335.228 366.966 333.569C360.866 331.91 356.274 326.775 353.823 319.708C351.372 312.642 351.085 303.695 353.572 294.55C356.059 285.405 360.838 277.836 366.529 272.985C372.222 268.132 378.782 266.03 384.882 267.688Z" stroke="black" stroke-opacity="0.04" style="stroke:black;stroke-opacity:0.04;"/>
<path d="M386.5 257.496C382.693 263.016 375.137 262.483 375.5 257.496C377.5 229.996 371.854 202.791 367.5 183.496C366.239 177.906 373.001 174.104 379.5 183.496C388.613 196.666 392.891 248.23 386.5 257.496Z" fill="#CCCCCC" style="fill:#CCCCCC;fill:color(display-p3 0.8000 0.8000 0.8000);fill-opacity:1;"/>
<path d="M370 176.496C371.5 175.996 374 175.496 376.231 177.485C396.517 198.295 392.652 248.406 388.482 257.727C387.431 261.866 383.968 264.223 380.862 263.996C379.31 263.883 377.763 263.337 376.596 262.36C375.4 261.358 373.873 259.186 374.003 257.388L374.176 254.821C375.777 228.725 372.876 202.547 366.418 184.889C365.105 181.359 366.588 178.202 370 176.496ZM375 179.996C373.834 178.697 372.21 177.901 370.5 178.496C367.68 179.477 367.5 182.496 368.5 184.496C374.752 194.273 378.559 221.266 377.169 255.013L376.996 257.605C376.945 258.3 377.222 258.883 377.796 259.364C378.4 259.87 379.317 260.232 380.354 260.308C382.426 260.459 384.447 259.487 385.046 257.127C388.158 250.255 392.607 199.609 375 179.996Z" fill="black" style="fill:black;fill-opacity:1;"/>
<path d="M373.5 173.996L370.5 189.996L367.5 193.996L364 192.496L359 177.996L368.5 165.996L373.5 173.996Z" fill="#935C0A" style="fill:#935C0A;fill:color(display-p3 0.5765 0.3608 0.0392);fill-opacity:1;"/>
<path d="M334.877 156.978C334.304 156.204 334.419 155.109 335.216 154.57C343.088 149.243 356.425 151.18 355.184 162.576C376.636 157.278 380.779 181.596 370.874 193.055C370.364 193.646 369.53 193.815 368.802 193.534V193.534C367.326 192.964 367.022 190.863 367.95 189.582C374.451 180.604 371.377 160.93 352.916 169.056L351.571 169.981C349.945 171.098 347.855 169.437 348.576 167.602L349.173 166.082C352.702 154.687 345.867 151.813 337.532 157.388C336.671 157.964 335.493 157.811 334.877 156.978V156.978Z" fill="black" style="fill:black;fill-opacity:1;"/>
</g>
<defs>
<radialGradient id="paint0_radial_2704_2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(267.775 321.564) scale(28.341 22.842)">
<stop stop-color="#E86A58" stop-opacity="0.18" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.18;"/>
<stop offset="0.3" stop-color="#E86A58" stop-opacity="0.16" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.16;"/>
<stop offset="0.63" stop-color="#E86A58" stop-opacity="0.1" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.1;"/>
<stop offset="0.99" stop-color="#E86A58" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
<stop offset="1" stop-color="#E86A58" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<radialGradient id="paint1_radial_2704_2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(149.715 321.643) rotate(9.39525) scale(28.341 22.842)">
<stop stop-color="#E86A58" stop-opacity="0.18" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.18;"/>
<stop offset="0.3" stop-color="#E86A58" stop-opacity="0.16" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.16;"/>
<stop offset="0.63" stop-color="#E86A58" stop-opacity="0.1" style="stop-color:#E86A58;stop-color:color(display-p3 0.9098 0.4157 0.3451);stop-opacity:0.1;"/>
<stop offset="0.99" stop-color="#E86A58" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
<stop offset="1" stop-color="#E86A58" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
</radialGradient>
<linearGradient id="paint2_linear_2704_2" x1="375.924" y1="265.992" x2="382.735" y2="334.91" gradientUnits="userSpaceOnUse">
<stop stop-color="#BABABA" style="stop-color:#BABABA;stop-color:color(display-p3 0.7275 0.7275 0.7275);stop-opacity:1;"/>
<stop offset="0.829668" stop-color="#B3B3B3" style="stop-color:#B3B3B3;stop-color:color(display-p3 0.7020 0.7020 0.7020);stop-opacity:1;"/>
</linearGradient>
<clipPath id="clip0_2704_2">
<rect width="497" height="497" fill="white" style="fill:white;fill-opacity:1;"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -3,4 +3,4 @@
@import './variables.scss';
@import './fonts.scss';
@import './themes.scss';
@import './themes.scss';

View file

@ -1,6 +1,9 @@
<script lang="ts">
import { spring } from 'svelte/motion'
import type { Album } from '$lib/types/lastfm'
import { audioPreview } from '$lib/stores/audio-preview'
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
import NowPlaying from './NowPlaying.svelte'
interface AlbumProps {
album?: Album
@ -9,6 +12,19 @@
let { album = undefined }: AlbumProps = $props()
let isHovering = $state(false)
let audio: HTMLAudioElement | null = $state(null)
// Create a unique ID for this album
const albumId = $derived(album ? `${album.artist.name}-${album.name}` : '')
// Subscribe to the store to know if this album is playing
let isPlaying = $state(false)
$effect(() => {
const unsubscribe = audioPreview.subscribe((state) => {
isPlaying = state.currentAlbumId === albumId && state.isPlaying
})
return unsubscribe
})
const scale = spring(1, {
stiffness: 0.2,
@ -22,31 +38,115 @@
scale.set(1)
}
})
async function togglePreview(e: Event) {
e.preventDefault()
e.stopPropagation()
if (!audio && album?.appleMusicData?.previewUrl) {
audio = new Audio(album.appleMusicData.previewUrl)
audio.addEventListener('ended', () => {
audioPreview.stop()
})
}
if (audio) {
if (isPlaying) {
audioPreview.stop()
} else {
// Update the store first, then play
audioPreview.play(audio, albumId)
try {
await audio.play()
} catch (error) {
console.error('Failed to play preview:', error)
audioPreview.stop()
}
}
}
}
$effect(() => {
// Cleanup audio when component unmounts
return () => {
if (audio && isPlaying) {
audioPreview.stop()
}
}
})
// Use high-res artwork if available
const artworkUrl = $derived(
album?.appleMusicData?.highResArtwork || album?.images.itunes || album?.images.mega || ''
)
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
// Subscribe to real-time now playing updates
let realtimeNowPlaying = $state<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null>(null)
$effect(() => {
if (album) {
const unsubscribe = nowPlayingStream.isAlbumPlaying.subscribe((checkAlbum) => {
const status = checkAlbum(album.artist.name, album.name)
if (status !== null) {
realtimeNowPlaying = status
}
})
return unsubscribe
}
})
// Combine initial state with real-time updates
const isNowPlaying = $derived(realtimeNowPlaying?.isNowPlaying ?? album?.isNowPlaying ?? false)
const nowPlayingTrack = $derived(realtimeNowPlaying?.nowPlayingTrack ?? album?.nowPlayingTrack)
</script>
<div class="album">
{#if album}
<a
href={album.url}
target="_blank"
rel="noopener noreferrer"
onmouseenter={() => (isHovering = true)}
onmouseleave={() => (isHovering = false)}
>
<img
src={album.images.itunes ? album.images.itunes : album.images.mega}
alt={album.name}
style="transform: scale({$scale})"
/>
<div class="info">
<span class="album-name">
{album.name}
</span>
<p class="artist-name">
{album.artist.name}
</p>
</div>
</a>
<div class="album-wrapper">
<a
href={album.url}
target="_blank"
rel="noopener noreferrer"
onmouseenter={() => (isHovering = true)}
onmouseleave={() => (isHovering = false)}
>
<div class="artwork-container">
<img
src={artworkUrl}
alt={album.name}
style="transform: scale({$scale})"
loading="lazy"
/>
{#if isNowPlaying}
<NowPlaying trackName={nowPlayingTrack !== album.name ? nowPlayingTrack : undefined} />
{/if}
{#if hasPreview && isHovering}
<button
class="preview-button"
onclick={togglePreview}
aria-label={isPlaying ? 'Pause preview' : 'Play preview'}
class:playing={isPlaying}
>
{#if isPlaying}
<span aria-hidden="true">❚❚</span>
{:else}
<span aria-hidden="true"></span>
{/if}
</button>
{/if}
</div>
<div class="info">
<span class="album-name">
{album.name}
</span>
<p class="artist-name">
{album.artist.name}
</p>
</div>
</a>
</div>
{:else}
<p>No album provided</p>
{/if}
@ -57,6 +157,14 @@
width: 100%;
height: 100%;
.album-wrapper {
display: flex;
flex-direction: column;
gap: $unit;
width: 100%;
height: 100%;
}
a {
display: flex;
flex-direction: column;
@ -66,6 +174,12 @@
width: 100%;
height: 100%;
.artwork-container {
position: relative;
width: 100%;
overflow: hidden;
}
img {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: $unit;
@ -75,6 +189,34 @@
object-fit: cover;
}
.preview-button {
position: absolute;
bottom: $unit;
right: $unit;
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.8);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s ease;
backdrop-filter: blur(10px);
&:hover {
background: rgba(0, 0, 0, 0.9);
transform: scale(1.1);
}
&.playing {
background: $accent-color;
}
}
.info {
display: flex;
flex-direction: column;
@ -103,5 +245,9 @@
}
}
}
.preview-container {
margin-top: $unit;
}
}
</style>

View file

@ -3,9 +3,11 @@
// We can do a thought bubble-y thing with the album art that takes you to the album section of the page
import { onMount, onDestroy } from 'svelte'
import { spring } from 'svelte/motion'
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
let isHovering = false
let isBlinking = false
let isHovering = $state(false)
let isBlinking = $state(false)
let isPlayingMusic = $state(false)
const scale = spring(1, {
stiffness: 0.1,
@ -55,10 +57,17 @@
}
}, 4000)
// Subscribe to now playing updates
const unsubscribe = nowPlayingStream.subscribe((state) => {
// Check if any album is currently playing
isPlayingMusic = Array.from(state.updates.values()).some((update) => update.isNowPlaying)
})
return () => {
if (blinkInterval) {
clearInterval(blinkInterval)
}
unsubscribe()
}
})
</script>
@ -356,6 +365,75 @@
</clipPath>
</defs>
</svg>
<!-- Headphones overlay when playing music -->
{#if isPlayingMusic}
<div class="headphones-container">
<svg
width="86"
height="202"
viewBox="0 0 86 202"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style="width: 8.84px; height: auto; position: absolute; left: 68%; top: 32%; animation: fadeIn 0.3s ease-out;"
>
<path
d="M56.6854 195.667C63.0485 191.195 71.8296 179.809 77.087 168.9C89.0428 144.091 92.2289 102.907 65.7859 86.2014C45.9684 73.9065 19.7285 85.9377 9.43434 106.872C2.48924 121.207 -0.100899 137.51 0.00299303 151.553C0.0550549 158.587 0.783319 165.136 2.01973 170.671C3.2404 176.136 5.01771 180.892 7.33744 184.111L7.42625 184.234L7.52449 184.348C13.8609 191.696 24.8872 200.228 37.3464 201.171C43.6642 201.649 50.288 200.162 56.6854 195.667Z"
fill="#D9D9D9"
/>
<path
d="M59.5 190.756C69.2084 179.099 75.5 169.756 79.5 158.256C55.0216 178.249 18.4869 138.632 7 122.256L2.5 150.256L6 175.756L14 188.756C22.988 194.631 28.2191 197.535 39.5 198.756C47.3937 198.19 51.7776 196.59 59.5 190.756Z"
fill="#C4C4C4"
/>
<path
d="M65.7859 86.2014C92.2289 102.907 89.0428 144.091 77.087 168.9C71.8296 179.809 63.0485 191.195 56.6854 195.667C50.288 200.162 43.6642 201.649 37.3464 201.171C24.8872 200.228 14 193.756 7.52449 184.348L7.42625 184.234L7.33744 184.111C5.01771 180.892 3.2404 176.136 2.01973 170.671C0.783319 165.136 0.0550549 158.587 0.00299303 151.553C-0.100899 137.51 2.48924 121.207 9.43434 106.872C19.7285 85.9377 45.9684 73.9065 65.7859 86.2014ZM62.7309 92.4309C18.5 64.7561 -5.5 152.756 14 184.111C19.5 191.756 26.5 195.256 37 196.756C44.1521 197.778 49.5 196.256 55.5 191.756C88.5 161.756 91.5 115.756 62.7309 92.4309Z"
fill="#070610"
/>
<ellipse
cx="52.9243"
cy="148.889"
rx="23.6644"
ry="34.6366"
transform="rotate(15.2136 52.9243 148.889)"
fill="url(#paint0_linear_headphones)"
/>
<path
d="M61.8821 115.948C67.9817 117.607 72.5741 122.742 75.0255 129.81C77.4759 136.875 77.7642 145.823 75.2772 154.968C72.7902 164.113 68.0103 171.682 62.3192 176.533C56.6259 181.386 50.0655 183.488 43.9659 181.829C37.8664 180.17 33.2739 175.035 30.8225 167.968C28.3721 160.902 28.0848 151.955 30.5717 142.81C33.0587 133.665 37.8377 126.096 43.5288 121.245C49.2221 116.392 55.7825 114.29 61.8821 115.948Z"
stroke="black"
stroke-opacity="0.04"
/>
<path
d="M63.4999 105.756C59.6929 111.276 52.1372 110.743 52.4999 105.756C54.5 78.256 48.8535 51.051 44.4999 31.756C43.2386 26.1657 50.0012 22.3638 56.4999 31.756C65.6128 44.9263 69.8913 96.4896 63.4999 105.756Z"
fill="#CCCCCC"
/>
<path
d="M47 24.756C48.5 24.256 51 23.7561 53.231 25.7452C73.5171 46.5549 69.6524 96.6664 65.4819 105.987C64.4309 110.126 60.9683 112.483 57.8619 112.256C56.3101 112.143 54.7629 111.597 53.5962 110.62C52.3998 109.618 50.8727 107.446 51.0035 105.648L51.1763 103.081C52.777 76.9845 49.876 50.8066 43.4175 33.1486C42.1049 29.6189 43.5884 26.4618 47 24.756ZM52 28.256C50.8336 26.9568 49.2098 26.1611 47.5 26.756C44.6798 27.7374 44.5 30.756 45.5 32.756C51.752 42.5329 55.5593 69.5264 54.1695 103.273L53.9956 105.865C53.9453 106.56 54.2225 107.143 54.7964 107.624C55.4003 108.13 56.3169 108.492 57.354 108.568C59.4256 108.719 61.447 107.747 62.0464 105.387C65.1582 98.5154 69.6073 47.8691 52 28.256Z"
fill="black"
/>
<path
d="M50.5 22.2561L47.5 38.2561L44.5 42.2561L41 40.7561L36 26.2561L45.5 14.2561L50.5 22.2561Z"
fill="#935C0A"
/>
<path
d="M11.8771 5.23835C11.3044 4.46433 11.4186 3.36941 12.216 2.82978C20.0877 -2.49724 33.4255 -0.56012 32.1836 10.8355C53.6363 5.53796 57.7794 29.8565 47.8743 41.3154C47.3637 41.9061 46.5303 42.0748 45.8019 41.7937V41.7937C44.3261 41.2242 44.022 39.123 44.9497 37.8418C51.4507 28.8638 48.3771 9.19044 29.916 17.316L28.5706 18.241C26.9453 19.3584 24.8549 17.6975 25.576 15.8616L26.1729 14.3424C29.7019 2.94735 22.8674 0.0733882 14.5317 5.64813C13.6712 6.22364 12.4928 6.07057 11.8771 5.23835V5.23835Z"
fill="black"
/>
<defs>
<linearGradient
id="paint0_linear_headphones"
x1="52.9243"
y1="114.252"
x2="59.7348"
y2="183.17"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#BABABA" />
<stop offset="0.829668" stop-color="#B3B3B3" />
</linearGradient>
</defs>
</svg>
</div>
{/if}
</div>
<style lang="scss">
@ -365,10 +443,8 @@
height: var(--face-size);
display: inline-block;
position: relative;
&:hover {
transform: scale(1.25);
}
border-radius: $avatar-radius;
transition: transform 0.2s ease;
}
.svg-wrapper {
@ -411,9 +487,44 @@
opacity: 1;
}
:global(.face-container svg) {
.face-container > svg {
border-radius: $avatar-radius;
width: 100%;
height: 100%;
}
.headphones-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
svg {
position: absolute;
// The headphones should be 17% of the avatar width
// For a 52px avatar in header: 52px * 0.17 = 8.84px
// But we want it relative to the container which scales
width: calc(var(--face-size) * 0.17);
height: auto;
// Position based on 323px left, 152px top when avatar is 497x497
left: 72%; // Adjusted for proper positioning
top: 32%; // Adjusted for proper positioning
animation: fadeIn 0.3s ease-out;
transform-origin: center top; // Bob from the top
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -113,13 +113,6 @@
:global(svg) {
height: 100%;
width: 100%;
transition: transform 0.2s ease;
}
&:hover {
:global(svg) {
transform: scale(1.05);
}
}
}

View file

@ -0,0 +1,350 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fade } from 'svelte/transition'
interface Props {
previewUrl: string
albumName?: string
artistName?: string
onPlayStateChange?: (isPlaying: boolean) => void
}
let { previewUrl, albumName = '', artistName = '', onPlayStateChange }: Props = $props()
let audio: HTMLAudioElement | null = $state(null)
let isPlaying = $state(false)
let isLoading = $state(false)
let currentTime = $state(0)
let duration = $state(30) // Apple Music previews are 30 seconds
let volume = $state(1)
let hasError = $state(false)
$effect(() => {
if (audio) {
audio.volume = volume
}
})
$effect(() => {
onPlayStateChange?.(isPlaying)
})
onMount(() => {
// Listen for other audio elements playing
const handleAudioPlay = (e: Event) => {
const playingAudio = e.target as HTMLAudioElement
if (playingAudio !== audio && audio && !audio.paused) {
pause()
}
}
document.addEventListener('play', handleAudioPlay, true)
return () => {
document.removeEventListener('play', handleAudioPlay, true)
}
})
onDestroy(() => {
if (audio) {
audio.pause()
audio = null
}
})
function togglePlayPause() {
if (isPlaying) {
pause()
} else {
play()
}
}
async function play() {
if (!audio || hasError) return
isLoading = true
try {
await audio.play()
isPlaying = true
} catch (error) {
console.error('Failed to play preview:', error)
hasError = true
} finally {
isLoading = false
}
}
function pause() {
if (!audio) return
audio.pause()
isPlaying = false
}
function handleTimeUpdate() {
if (audio) {
currentTime = audio.currentTime
}
}
function handleEnded() {
isPlaying = false
currentTime = 0
if (audio) {
audio.currentTime = 0
}
}
function handleError() {
hasError = true
isPlaying = false
isLoading = false
}
function handleLoadedMetadata() {
if (audio) {
duration = audio.duration
}
}
function seek(e: MouseEvent) {
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const x = e.clientX - rect.left
const percentage = x / rect.width
const newTime = percentage * duration
if (audio) {
audio.currentTime = newTime
currentTime = newTime
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.code === 'Space') {
e.preventDefault()
togglePlayPause()
}
}
function formatTime(time: number): string {
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
const progressPercentage = $derived((currentTime / duration) * 100)
</script>
<div class="music-preview" role="region" aria-label="Music preview player">
<audio
bind:this={audio}
src={previewUrl}
onloadedmetadata={handleLoadedMetadata}
ontimeupdate={handleTimeUpdate}
onended={handleEnded}
onerror={handleError}
preload="metadata"
/>
<div class="controls">
<button
class="play-button"
onclick={togglePlayPause}
onkeydown={handleKeydown}
disabled={hasError || isLoading}
aria-label={isPlaying ? 'Pause' : 'Play'}
aria-pressed={isPlaying}
>
{#if isLoading}
<span class="loading-spinner" aria-hidden="true"></span>
{:else if hasError}
<span aria-hidden="true"></span>
{:else if isPlaying}
<span aria-hidden="true">❚❚</span>
{:else}
<span aria-hidden="true"></span>
{/if}
</button>
<div class="progress-container">
<div
class="progress-bar"
onclick={seek}
role="slider"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={currentTime}
tabindex="0"
>
<div class="progress-fill" style="width: {progressPercentage}%"></div>
</div>
<div class="time-display">
<span>{formatTime(currentTime)}</span>
<span>/</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div class="volume-control">
<label for="volume" class="visually-hidden">Volume</label>
<input
id="volume"
type="range"
bind:value={volume}
min="0"
max="1"
step="0.1"
aria-label="Volume control"
/>
</div>
</div>
{#if hasError}
<p class="error-message" transition:fade={{ duration: 200 }}>Preview unavailable</p>
{/if}
</div>
<style lang="scss">
.music-preview {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-sm);
background: var(--color-surface);
border-radius: var(--radius-md);
}
.controls {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.play-button {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background: var(--color-primary);
color: var(--color-primary-contrast);
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
transform: scale(1.05);
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading-spinner {
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.progress-container {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
}
.progress-bar {
position: relative;
height: 6px;
background: var(--color-surface-secondary);
border-radius: 3px;
cursor: pointer;
overflow: hidden;
&:hover .progress-fill {
background: var(--color-primary-hover);
}
}
.progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--color-primary);
transition: background 0.2s ease;
}
.time-display {
display: flex;
gap: var(--spacing-2xs);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.volume-control {
width: 80px;
input[type='range'] {
width: 100%;
height: 6px;
background: var(--color-surface-secondary);
border-radius: 3px;
outline: none;
-webkit-appearance: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: var(--color-primary);
border-radius: 50%;
cursor: pointer;
}
&::-moz-range-thumb {
width: 14px;
height: 14px;
background: var(--color-primary);
border-radius: 50%;
cursor: pointer;
border: none;
}
}
}
.error-message {
margin: 0;
padding: var(--spacing-xs);
font-size: var(--font-size-sm);
color: var(--color-error);
text-align: center;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>

View file

@ -0,0 +1,106 @@
<script lang="ts">
interface Props {
trackName?: string
}
let { trackName }: Props = $props()
</script>
<div class="now-playing">
<div class="equalizer" aria-label="Now playing">
<span class="bar"></span>
<span class="bar"></span>
<span class="bar"></span>
</div>
{#if trackName}
<span class="track-name">{trackName}</span>
{/if}
</div>
<style lang="scss">
.now-playing {
position: absolute;
top: $unit;
left: $unit;
display: flex;
align-items: center;
gap: $unit-half;
padding: $unit-half $unit;
background: rgba(0, 0, 0, 0.9);
color: white;
border-radius: $unit * 2;
font-size: $font-size-small;
backdrop-filter: blur(10px);
z-index: 10;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.equalizer {
display: flex;
align-items: flex-end;
gap: 2px;
height: 16px;
}
.bar {
width: 3px;
background: $accent-color;
animation: dance 0.6s ease-in-out infinite;
transform-origin: bottom;
}
.bar:nth-child(1) {
height: 40%;
animation-delay: 0s;
}
.bar:nth-child(2) {
height: 60%;
animation-delay: 0.2s;
}
.bar:nth-child(3) {
height: 50%;
animation-delay: 0.4s;
}
@keyframes dance {
0%,
100% {
transform: scaleY(1);
}
50% {
transform: scaleY(1.8);
}
}
.track-name {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: $font-weight-med;
}
@include breakpoint('phone') {
.now-playing {
font-size: $font-size-extra-small;
padding: $unit-fourth $unit-half;
}
.track-name {
display: none;
}
}
</style>

View file

@ -119,9 +119,7 @@
onmouseenter={() => (hoveredIndex = index)}
onmouseleave={() => (hoveredIndex = null)}
>
<item.icon
class="nav-icon {hoveredIndex === index ? 'animate' : ''}"
/>
<item.icon class="nav-icon {hoveredIndex === index ? 'animate' : ''}" />
<span>{item.text}</span>
</a>
{/each}

View file

@ -0,0 +1,79 @@
<script lang="ts">
import { nowPlayingStream } from '$lib/stores/now-playing-stream'
let isConnected = $state(false)
$effect(() => {
const unsubscribe = nowPlayingStream.subscribe((state) => {
isConnected = state.connected
})
return unsubscribe
})
</script>
{#if isConnected}
<div class="stream-status connected" title="Live updates active">
<span class="dot"></span>
</div>
{/if}
<style lang="scss">
.stream-status {
position: fixed;
bottom: $unit * 2;
right: $unit * 2;
display: flex;
align-items: center;
gap: $unit-half;
padding: $unit-half $unit;
background: rgba(0, 0, 0, 0.8);
color: white;
border-radius: $unit * 2;
font-size: $font-size-small;
z-index: 1000;
animation: fadeIn 0.3s ease-out;
&.connected {
.dot {
background: #4caf50;
animation: pulse 2s ease-in-out infinite;
}
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(0.9);
}
}
@include breakpoint('phone') {
.stream-status {
bottom: $unit;
right: $unit;
}
}
</style>

View file

@ -83,18 +83,18 @@
let mediaDropdownTriggerRef = $state<HTMLElement>()
let dropdownPosition = $state({ top: 0, left: 0 })
let mediaDropdownPosition = $state({ top: 0, left: 0 })
// URL convert dropdown state
let showUrlConvertDropdown = $state(false)
let urlConvertDropdownPosition = $state({ x: 0, y: 0 })
let urlConvertPos = $state<number | null>(null)
// Link context menu state
let showLinkContextMenu = $state(false)
let linkContextMenuPosition = $state({ x: 0, y: 0 })
let linkContextUrl = $state<string | null>(null)
let linkContextPos = $state<number | null>(null)
// Link edit dialog state
let showLinkEditDialog = $state(false)
let linkEditDialogPosition = $state({ x: 0, y: 0 })
@ -239,85 +239,89 @@
showLinkEditDialog = false
}
}
// Handle URL convert dropdown
const handleShowUrlConvertDropdown = (pos: number, url: string) => {
if (!editor) return
// Get the cursor coordinates
const coords = editor.view.coordsAtPos(pos)
urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 }
urlConvertPos = pos
showUrlConvertDropdown = true
}
// Handle link context menu
const handleShowLinkContextMenu = (pos: number, url: string, coords: { x: number, y: number }) => {
const handleShowLinkContextMenu = (
pos: number,
url: string,
coords: { x: number; y: number }
) => {
if (!editor) return
linkContextMenuPosition = { x: coords.x, y: coords.y + 5 }
linkContextUrl = url
linkContextPos = pos
showLinkContextMenu = true
}
const handleConvertToEmbed = () => {
if (!editor || urlConvertPos === null) return
editor.commands.convertLinkToEmbed(urlConvertPos)
showUrlConvertDropdown = false
urlConvertPos = null
}
const handleConvertLinkToEmbed = () => {
if (!editor || linkContextPos === null) return
editor.commands.convertLinkToEmbed(linkContextPos)
showLinkContextMenu = false
linkContextPos = null
linkContextUrl = null
}
const handleEditLink = () => {
if (!editor || !linkContextUrl) return
linkEditUrl = linkContextUrl
linkEditPos = linkContextPos
linkEditDialogPosition = { ...linkContextMenuPosition }
showLinkEditDialog = true
showLinkContextMenu = false
}
const handleSaveLink = (newUrl: string) => {
if (!editor) return
editor.chain().focus().extendMarkRange('link').setLink({ href: newUrl }).run()
showLinkEditDialog = false
linkEditPos = null
linkEditUrl = ''
}
const handleCopyLink = () => {
if (!linkContextUrl) return
navigator.clipboard.writeText(linkContextUrl)
showLinkContextMenu = false
linkContextPos = null
linkContextUrl = null
}
const handleRemoveLink = () => {
if (!editor) return
editor.chain().focus().extendMarkRange('link').unsetLink().run()
showLinkContextMenu = false
linkContextPos = null
linkContextUrl = null
}
const handleOpenLink = () => {
if (!linkContextUrl) return
window.open(linkContextUrl, '_blank', 'noopener,noreferrer')
showLinkContextMenu = false
linkContextPos = null
@ -325,7 +329,13 @@
}
$effect(() => {
if (showTextStyleDropdown || showMediaDropdown || showUrlConvertDropdown || showLinkContextMenu || showLinkEditDialog) {
if (
showTextStyleDropdown ||
showMediaDropdown ||
showUrlConvertDropdown ||
showLinkContextMenu ||
showLinkEditDialog
) {
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
@ -484,16 +494,16 @@
// Dismiss URL convert dropdown if user types
if (showUrlConvertDropdown && transaction.docChanged) {
// Check if the change is actual typing (not just cursor movement)
const hasTextChange = transaction.steps.some(step =>
step.toJSON().stepType === 'replace' ||
step.toJSON().stepType === 'replaceAround'
const hasTextChange = transaction.steps.some(
(step) =>
step.toJSON().stepType === 'replace' || step.toJSON().stepType === 'replaceAround'
)
if (hasTextChange) {
showUrlConvertDropdown = false
urlConvertPos = null
}
}
// Call the original onUpdate if provided
if (onUpdate) {
onUpdate({ editor: updatedEditor, transaction })

View file

@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
export interface LinkContextMenuOptions {
onShowContextMenu?: (pos: number, url: string, coords: { x: number, y: number }) => void
onShowContextMenu?: (pos: number, url: string, coords: { x: number; y: number }) => void
}
export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
@ -16,7 +16,7 @@ export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
addProseMirrorPlugins() {
const options = this.options
return [
new Plugin({
key: new PluginKey('linkContextMenu'),
@ -25,26 +25,26 @@ export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
contextmenu: (view, event) => {
const { state } = view
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) return false
const $pos = state.doc.resolve(pos.pos)
const marks = $pos.marks()
const linkMark = marks.find(mark => mark.type.name === 'link')
const linkMark = marks.find((mark) => mark.type.name === 'link')
if (linkMark && linkMark.attrs.href) {
event.preventDefault()
if (options.onShowContextMenu) {
options.onShowContextMenu(pos.pos, linkMark.attrs.href, {
x: event.clientX,
y: event.clientY
})
}
return true
}
return false
}
}
@ -52,4 +52,4 @@ export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
})
]
}
})
})

View file

@ -78,7 +78,10 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes({ 'data-url-embed': '' }, this.options.HTMLAttributes, HTMLAttributes)]
return [
'div',
mergeAttributes({ 'data-url-embed': '' }, this.options.HTMLAttributes, HTMLAttributes)
]
},
addCommands() {
@ -102,35 +105,41 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
(pos) =>
({ state, commands, chain }) => {
const { doc } = state
// Find the link mark at the given position
const $pos = doc.resolve(pos)
const marks = $pos.marks()
const linkMark = marks.find(mark => mark.type.name === 'link')
const linkMark = marks.find((mark) => mark.type.name === 'link')
if (!linkMark) return false
const url = linkMark.attrs.href
if (!url) return false
// Find the complete range of text with this link mark
let from = pos
let to = pos
// Walk backwards to find the start
doc.nodesBetween(Math.max(0, pos - 300), pos, (node, nodePos) => {
if (node.isText && node.marks.some(m => m.type.name === 'link' && m.attrs.href === url)) {
if (
node.isText &&
node.marks.some((m) => m.type.name === 'link' && m.attrs.href === url)
) {
from = nodePos
}
})
// Walk forwards to find the end
doc.nodesBetween(pos, Math.min(doc.content.size, pos + 300), (node, nodePos) => {
if (node.isText && node.marks.some(m => m.type.name === 'link' && m.attrs.href === url)) {
if (
node.isText &&
node.marks.some((m) => m.type.name === 'link' && m.attrs.href === url)
) {
to = nodePos + node.nodeSize
}
})
// Use Tiptap's chain commands to replace content
return chain()
.focus()
@ -179,40 +188,53 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
// Check if it's a plain text paste
if (text && !html) {
// Simple URL regex check
const urlRegex = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/
const urlRegex =
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/
if (urlRegex.test(text.trim())) {
// It's a URL, let it paste as a link naturally (don't prevent default)
// But track it so we can show dropdown after
const pastedUrl = text.trim()
// Get the position before paste
const beforePos = view.state.selection.from
setTimeout(() => {
const { state } = view
const { doc } = state
// Find the link that was just inserted
// Start from where we were before paste
let linkStart = -1
let linkEnd = -1
// Search for the link in a reasonable range
for (let pos = beforePos; pos < Math.min(doc.content.size, beforePos + pastedUrl.length + 10); pos++) {
for (
let pos = beforePos;
pos < Math.min(doc.content.size, beforePos + pastedUrl.length + 10);
pos++
) {
try {
const $pos = doc.resolve(pos)
const marks = $pos.marks()
const linkMark = marks.find(m => m.type.name === 'link' && m.attrs.href === pastedUrl)
const linkMark = marks.find(
(m) => m.type.name === 'link' && m.attrs.href === pastedUrl
)
if (linkMark) {
// Found the link, now find its boundaries
linkStart = pos
// Find the end of the link
for (let endPos = pos; endPos < Math.min(doc.content.size, pos + pastedUrl.length + 5); endPos++) {
for (
let endPos = pos;
endPos < Math.min(doc.content.size, pos + pastedUrl.length + 5);
endPos++
) {
const $endPos = doc.resolve(endPos)
const hasLink = $endPos.marks().some(m => m.type.name === 'link' && m.attrs.href === pastedUrl)
const hasLink = $endPos
.marks()
.some((m) => m.type.name === 'link' && m.attrs.href === pastedUrl)
if (hasLink) {
linkEnd = endPos + 1
} else {
@ -225,7 +247,7 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
// Position might be invalid, continue
}
}
if (linkStart !== -1) {
// Store the pasted URL info with correct position
const tr = state.tr.setMeta('urlEmbedPaste', {
@ -233,7 +255,7 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
lastPastedPos: linkStart
})
view.dispatch(tr)
// Notify the editor to show dropdown
if (options.onShowDropdown) {
options.onShowDropdown(linkStart, pastedUrl)
@ -251,4 +273,4 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
})
]
}
})
})

View file

@ -49,4 +49,4 @@ export const UrlEmbedExtended = (component: any) =>
addNodeView() {
return SvelteNodeViewRenderer(component)
}
})
})

View file

@ -32,4 +32,4 @@ export const UrlEmbedPlaceholder = (component: any) =>
addNodeView() {
return SvelteNodeViewRenderer(component)
}
})
})

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fly } from 'svelte/transition'
interface Props {
x: number
y: number
@ -13,29 +13,39 @@
onRemove: () => void
onDismiss: () => void
}
let { x, y, url, onConvertToLink, onCopyLink, onRefresh, onOpenLink, onRemove, onDismiss }: Props = $props()
let {
x,
y,
url,
onConvertToLink,
onCopyLink,
onRefresh,
onOpenLink,
onRemove,
onDismiss
}: Props = $props()
let dropdown: HTMLDivElement
function handleClickOutside(event: MouseEvent) {
if (dropdown && !dropdown.contains(event.target as Node)) {
onDismiss()
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onDismiss()
}
}
onMount(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
dropdown?.focus()
})
onDestroy(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
@ -51,28 +61,18 @@
>
<div class="menu-url">{url}</div>
<div class="menu-divider"></div>
<button class="menu-item" onclick={onOpenLink}>
Open link
</button>
<button class="menu-item" onclick={onCopyLink}>
Copy link
</button>
<button class="menu-item" onclick={onRefresh}>
Refresh preview
</button>
<button class="menu-item" onclick={onConvertToLink}>
Convert to link
</button>
<button class="menu-item" onclick={onOpenLink}> Open link </button>
<button class="menu-item" onclick={onCopyLink}> Copy link </button>
<button class="menu-item" onclick={onRefresh}> Refresh preview </button>
<button class="menu-item" onclick={onConvertToLink}> Convert to link </button>
<div class="menu-divider"></div>
<button class="menu-item danger" onclick={onRemove}>
Remove card
</button>
<button class="menu-item danger" onclick={onRemove}> Remove card </button>
</div>
<style lang="scss">
@ -88,7 +88,7 @@
min-width: 200px;
max-width: 300px;
}
.menu-url {
padding: $unit $unit-2x;
font-size: 0.75rem;
@ -97,13 +97,13 @@
overflow: hidden;
text-overflow: ellipsis;
}
.menu-divider {
height: 1px;
background-color: $grey-90;
margin: 4px 0;
}
.menu-item {
display: block;
width: 100%;
@ -116,18 +116,18 @@
color: $grey-20;
text-align: left;
transition: background-color 0.2s;
&:hover {
background-color: $grey-95;
}
&:focus {
outline: 2px solid $red-60;
outline-offset: -2px;
}
&.danger {
color: $red-60;
}
}
</style>
</style>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fly } from 'svelte/transition'
interface Props {
x: number
y: number
@ -13,29 +13,39 @@
onOpenLink: () => void
onDismiss: () => void
}
let { x, y, url, onConvertToCard, onEditLink, onCopyLink, onRemoveLink, onOpenLink, onDismiss }: Props = $props()
let {
x,
y,
url,
onConvertToCard,
onEditLink,
onCopyLink,
onRemoveLink,
onOpenLink,
onDismiss
}: Props = $props()
let dropdown: HTMLDivElement
function handleClickOutside(event: MouseEvent) {
if (dropdown && !dropdown.contains(event.target as Node)) {
onDismiss()
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onDismiss()
}
}
onMount(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
dropdown?.focus()
})
onDestroy(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
@ -51,28 +61,18 @@
>
<div class="menu-url">{url}</div>
<div class="menu-divider"></div>
<button class="menu-item" onclick={onOpenLink}>
Open link
</button>
<button class="menu-item" onclick={onEditLink}>
Edit link
</button>
<button class="menu-item" onclick={onCopyLink}>
Copy link
</button>
<button class="menu-item" onclick={onConvertToCard}>
Convert to card
</button>
<button class="menu-item" onclick={onOpenLink}> Open link </button>
<button class="menu-item" onclick={onEditLink}> Edit link </button>
<button class="menu-item" onclick={onCopyLink}> Copy link </button>
<button class="menu-item" onclick={onConvertToCard}> Convert to card </button>
<div class="menu-divider"></div>
<button class="menu-item danger" onclick={onRemoveLink}>
Remove link
</button>
<button class="menu-item danger" onclick={onRemoveLink}> Remove link </button>
</div>
<style lang="scss">
@ -88,7 +88,7 @@
min-width: 200px;
max-width: 300px;
}
.menu-url {
padding: $unit $unit-2x;
font-size: 0.75rem;
@ -97,13 +97,13 @@
overflow: hidden;
text-overflow: ellipsis;
}
.menu-divider {
height: 1px;
background-color: $grey-90;
margin: 4px 0;
}
.menu-item {
display: block;
width: 100%;
@ -116,18 +116,18 @@
color: $grey-20;
text-align: left;
transition: background-color 0.2s;
&:hover {
background-color: $grey-95;
}
&:focus {
outline: 2px solid $red-60;
outline-offset: -2px;
}
&.danger {
color: $red-60;
}
}
</style>
</style>

View file

@ -3,7 +3,7 @@
import { fly } from 'svelte/transition'
import Check from 'lucide-svelte/icons/check'
import X from 'lucide-svelte/icons/x'
interface Props {
x: number
y: number
@ -11,13 +11,13 @@
onSave: (url: string) => void
onCancel: () => void
}
let { x, y, currentUrl, onSave, onCancel }: Props = $props()
let urlInput = $state(currentUrl)
let inputElement: HTMLInputElement
let dialogElement: HTMLDivElement
const isValid = $derived(() => {
if (!urlInput.trim()) return false
try {
@ -33,19 +33,19 @@
}
}
})
function handleSave() {
if (!isValid) return
let finalUrl = urlInput.trim()
// Add https:// if no protocol
if (!finalUrl.match(/^https?:\/\//)) {
finalUrl = 'https://' + finalUrl
}
onSave(finalUrl)
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && isValid) {
event.preventDefault()
@ -55,7 +55,7 @@
onCancel()
}
}
onMount(() => {
inputElement?.focus()
inputElement?.select()
@ -79,19 +79,10 @@
class:invalid={urlInput && !isValid}
/>
<div class="dialog-actions">
<button
class="action-button save"
onclick={handleSave}
disabled={!isValid}
title="Save"
>
<button class="action-button save" onclick={handleSave} disabled={!isValid} title="Save">
<Check />
</button>
<button
class="action-button cancel"
onclick={onCancel}
title="Cancel"
>
<button class="action-button cancel" onclick={onCancel} title="Cancel">
<X />
</button>
</div>
@ -110,13 +101,13 @@
outline: none;
min-width: 300px;
}
.dialog-content {
display: flex;
gap: $unit;
align-items: center;
}
.url-input {
flex: 1;
padding: $unit $unit-2x;
@ -126,22 +117,22 @@
color: $grey-20;
background: white;
transition: border-color 0.2s;
&:focus {
outline: none;
border-color: $red-60;
}
&.invalid {
border-color: $red-60;
}
}
.dialog-actions {
display: flex;
gap: 4px;
}
.action-button {
display: flex;
align-items: center;
@ -155,35 +146,35 @@
cursor: pointer;
transition: all 0.2s;
color: $grey-40;
svg {
width: 16px;
height: 16px;
}
&:hover:not(:disabled) {
background-color: $grey-95;
color: $grey-20;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.save:not(:disabled) {
color: $red-60;
border-color: $red-60;
&:hover {
background-color: $red-60;
color: white;
}
}
&.cancel:hover {
color: $red-60;
border-color: $red-60;
}
}
</style>
</style>

View file

@ -1,28 +1,28 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { fly } from 'svelte/transition'
interface Props {
x: number
y: number
onConvert: () => void
onDismiss: () => void
}
let { x, y, onConvert, onDismiss }: Props = $props()
let dropdown: HTMLDivElement
function handleConvert() {
onConvert()
}
function handleClickOutside(event: MouseEvent) {
if (dropdown && !dropdown.contains(event.target as Node)) {
onDismiss()
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onDismiss()
@ -30,16 +30,16 @@
handleConvert()
}
}
onMount(() => {
// Add event listeners
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
// Don't focus the dropdown - this steals focus from the editor
// dropdown?.focus()
})
onDestroy(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
@ -53,9 +53,7 @@
transition:fly={{ y: -10, duration: 200 }}
tabindex="-1"
>
<button class="convert-button" onclick={handleConvert}>
Convert to card
</button>
<button class="convert-button" onclick={handleConvert}> Convert to card </button>
</div>
<style lang="scss">
@ -70,7 +68,7 @@
outline: none;
min-width: 160px;
}
.convert-button {
display: block;
width: 100%;
@ -84,14 +82,14 @@
white-space: nowrap;
transition: background-color 0.2s;
text-align: left;
&:hover {
background-color: $grey-95;
}
&:focus {
outline: 2px solid $red-60;
outline-offset: -2px;
}
}
</style>
</style>

View file

@ -7,7 +7,7 @@
import { onMount } from 'svelte'
const { editor, node, deleteNode, getPos }: NodeViewProps = $props()
let loading = $state(true)
let error = $state(false)
let errorMessage = $state('')
@ -26,32 +26,29 @@
}
const metadata = await response.json()
// Replace this placeholder with the actual URL embed
const pos = getPos()
if (typeof pos === 'number') {
editor
.chain()
.focus()
.insertContentAt(
{ from: pos, to: pos + node.nodeSize },
[
{
type: 'urlEmbed',
attrs: {
url: url,
title: metadata.title,
description: metadata.description,
image: metadata.image,
favicon: metadata.favicon,
siteName: metadata.siteName
}
},
{
type: 'paragraph'
.insertContentAt({ from: pos, to: pos + node.nodeSize }, [
{
type: 'urlEmbed',
attrs: {
url: url,
title: metadata.title,
description: metadata.description,
image: metadata.image,
favicon: metadata.favicon,
siteName: metadata.siteName
}
]
)
},
{
type: 'paragraph'
}
])
.run()
}
} catch (err) {
@ -64,7 +61,7 @@
function handleSubmit() {
if (!inputUrl.trim()) return
// Basic URL validation
try {
new URL(inputUrl)
@ -88,7 +85,7 @@
function handleClick(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
if (!showInput) {
showInput = true
}
@ -126,7 +123,13 @@
<AlertCircle class="placeholder-icon" />
<div class="error-content">
<span class="placeholder-text">{errorMessage}</span>
<button onclick={() => { showInput = true; error = false; }} class="retry-button">
<button
onclick={() => {
showInput = true
error = false
}}
class="retry-button"
>
Try another URL
</button>
</div>
@ -167,7 +170,7 @@
border-radius: 6px;
font-size: 0.875rem;
background: white;
&:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
@ -274,4 +277,4 @@
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
</style>

View file

@ -0,0 +1,66 @@
import jwt from 'jsonwebtoken'
import { readFileSync } from 'fs'
import { env } from '$env/dynamic/private'
let cachedToken: string | null = null
let tokenExpiry: Date | null = null
export function generateAppleMusicToken(): string {
// Check if we have a valid cached token
if (cachedToken && tokenExpiry && tokenExpiry > new Date()) {
console.log('Using cached Apple Music token')
return cachedToken
}
console.log('Generating new Apple Music token...')
console.log('Team ID:', env.APPLE_MUSIC_TEAM_ID)
console.log('Key ID:', env.APPLE_MUSIC_KEY_ID)
console.log('Key path configured:', !!env.APPLE_MUSIC_PRIVATE_KEY_PATH)
// Read the private key - support both file path and direct content
let privateKey: string
if (env.APPLE_MUSIC_PRIVATE_KEY) {
// Direct key content from environment variable
privateKey = env.APPLE_MUSIC_PRIVATE_KEY
} else if (env.APPLE_MUSIC_PRIVATE_KEY_PATH) {
// File path (for local development)
try {
privateKey = readFileSync(env.APPLE_MUSIC_PRIVATE_KEY_PATH, 'utf8')
console.log('Successfully read private key from file')
} catch (error) {
console.error('Failed to read private key file:', error)
throw error
}
} else {
throw new Error('Apple Music private key not configured')
}
// Token expires in 6 months (max allowed by Apple)
const expiresIn = 15552000 // 180 days in seconds
// Generate the token
const token = jwt.sign({}, privateKey, {
algorithm: 'ES256',
expiresIn,
issuer: env.APPLE_MUSIC_TEAM_ID!,
header: {
alg: 'ES256',
kid: env.APPLE_MUSIC_KEY_ID!
}
})
// Cache the token
cachedToken = token
tokenExpiry = new Date(Date.now() + expiresIn * 1000)
return token
}
export function getAppleMusicHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${generateAppleMusicToken()}`,
'Music-User-Token': '', // Will be needed for user-specific features
Accept: 'application/json',
'Content-Type': 'application/json'
}
}

View file

@ -0,0 +1,296 @@
import { getAppleMusicHeaders } from './apple-music-auth'
import type {
AppleMusicAlbum,
AppleMusicTrack,
AppleMusicSearchResponse,
AppleMusicErrorResponse
} from '$lib/types/apple-music'
import { isAppleMusicError } from '$lib/types/apple-music'
import { ApiRateLimiter } from './rate-limiter'
const APPLE_MUSIC_API_BASE = 'https://api.music.apple.com/v1'
const DEFAULT_STOREFRONT = 'us' // Default to US storefront
const RATE_LIMIT_DELAY = 200 // 200ms between requests to stay well under 3000/hour
let lastRequestTime = 0
const rateLimiter = new ApiRateLimiter('apple-music')
async function rateLimitedFetch(url: string, options?: RequestInit): Promise<Response> {
const now = Date.now()
const timeSinceLastRequest = now - lastRequestTime
if (timeSinceLastRequest < RATE_LIMIT_DELAY) {
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY - timeSinceLastRequest))
}
lastRequestTime = Date.now()
return fetch(url, options)
}
async function makeAppleMusicRequest<T>(endpoint: string, identifier?: string): Promise<T> {
// Check if we should block this request
if (identifier && (await rateLimiter.shouldBlock(identifier))) {
throw new Error('Request blocked due to rate limiting')
}
const url = `${APPLE_MUSIC_API_BASE}${endpoint}`
const headers = getAppleMusicHeaders()
console.log('Making Apple Music API request:', {
url,
headers: {
...headers,
Authorization: headers.Authorization ? 'Bearer [TOKEN]' : 'Missing'
}
})
try {
const response = await rateLimitedFetch(url, { headers })
if (!response.ok) {
const errorText = await response.text()
console.error('Apple Music API error response:', {
status: response.status,
statusText: response.statusText,
body: errorText
})
// Record failure and handle rate limiting
if (identifier) {
await rateLimiter.recordFailure(identifier, response.status === 429)
}
try {
const errorData = JSON.parse(errorText)
if (isAppleMusicError(errorData)) {
throw new Error(
`Apple Music API Error: ${errorData.errors[0]?.detail || 'Unknown error'}`
)
}
} catch (e) {
// If not JSON, throw the text error
}
throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`)
}
// Record success
if (identifier) {
await rateLimiter.recordSuccess(identifier)
}
return await response.json()
} catch (error) {
console.error('Apple Music API request failed:', error)
throw error
}
}
export async function searchAlbums(
query: string,
limit: number = 10
): Promise<AppleMusicSearchResponse> {
const encodedQuery = encodeURIComponent(query)
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/search?types=albums&term=${encodedQuery}&limit=${limit}`
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
}
export async function searchTracks(
query: string,
limit: number = 10
): Promise<AppleMusicSearchResponse> {
const encodedQuery = encodeURIComponent(query)
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/search?types=songs&term=${encodedQuery}&limit=${limit}`
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint, query)
}
export async function getAlbum(id: string): Promise<{ data: AppleMusicAlbum[] }> {
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${id}`
return makeAppleMusicRequest<{ data: AppleMusicAlbum[] }>(endpoint, `album:${id}`)
}
export async function getAlbumWithTracks(id: string): Promise<{ data: AppleMusicAlbum[] }> {
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${id}?include=tracks`
return makeAppleMusicRequest<{ data: AppleMusicAlbum[] }>(endpoint, `album:${id}`)
}
// Get album with all details including tracks for preview URLs
export async function getAlbumDetails(id: string): Promise<AppleMusicAlbum | null> {
try {
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${id}?include=tracks`
const response = await makeAppleMusicRequest<{
data: AppleMusicAlbum[]
included?: AppleMusicTrack[]
}>(endpoint, `album:${id}`)
console.log(`Album details for ${id}:`, {
hasData: !!response.data?.[0],
hasRelationships: !!response.data?.[0]?.relationships,
hasTracks: !!response.data?.[0]?.relationships?.tracks,
hasIncluded: !!response.included,
includedCount: response.included?.length || 0
})
// Check if tracks are in the included array
if (response.included?.length) {
console.log('First included track:', JSON.stringify(response.included[0], null, 2))
}
return response.data?.[0] || null
} catch (error) {
console.error(`Failed to get album details for ID ${id}:`, error)
return null
}
}
export async function getTrack(id: string): Promise<{ data: AppleMusicTrack[] }> {
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/songs/${id}`
return makeAppleMusicRequest<{ data: AppleMusicTrack[] }>(endpoint, `track:${id}`)
}
// Helper function to search for an album by artist and album name
export async function findAlbum(artist: string, album: string): Promise<AppleMusicAlbum | null> {
const identifier = `${artist}:${album}`
// Check if this album was already marked as not found
if (await rateLimiter.isNotFoundCached(identifier)) {
console.log(`Album "${album}" by "${artist}" is cached as not found`)
return null
}
try {
const searchQuery = `${artist} ${album}`
const response = await searchAlbums(searchQuery, 5)
console.log(`Search results for "${searchQuery}":`, JSON.stringify(response, null, 2))
if (!response.results?.albums?.data?.length) {
console.log('No albums found in search results')
// Cache this as not found for 1 hour
await rateLimiter.cacheNotFound(identifier, 3600)
return null
}
// Try to find the best match
const albums = response.results.albums.data
console.log(`Found ${albums.length} albums`)
// First try exact match
let match = albums.find(
(a) =>
a.attributes?.name?.toLowerCase() === album.toLowerCase() &&
a.attributes?.artistName?.toLowerCase() === artist.toLowerCase()
)
// If no exact match, try partial match
if (!match) {
match = albums.find(
(a) =>
a.attributes?.name?.toLowerCase().includes(album.toLowerCase()) &&
a.attributes?.artistName?.toLowerCase().includes(artist.toLowerCase())
)
}
// If still no match, cache as not found
if (!match) {
await rateLimiter.cacheNotFound(identifier, 3600)
return null
}
// Return the match
return match
} catch (error) {
console.error(`Failed to find album "${album}" by "${artist}":`, error)
// Don't cache as not found on error - might be temporary
return null
}
}
// Transform Apple Music album data to match existing format
export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
const attributes = appleMusicAlbum.attributes
// Get preview URL from tracks if album doesn't have one
let previewUrl = attributes.previews?.[0]?.url
let tracks: Array<{ name: string; previewUrl?: string; durationMs?: number }> = []
// Always fetch tracks to get preview URLs
if (appleMusicAlbum.id) {
try {
// Fetch album details with tracks
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${appleMusicAlbum.id}?include=tracks`
const response = await makeAppleMusicRequest<{
data: AppleMusicAlbum[]
included?: AppleMusicTrack[]
}>(endpoint, `album:${appleMusicAlbum.id}`)
console.log(`Album details response structure:`, {
hasData: !!response.data,
dataLength: response.data?.length,
hasIncluded: !!response.included,
includedLength: response.included?.length,
// Check if tracks are in relationships
hasRelationships: !!response.data?.[0]?.relationships,
hasTracks: !!response.data?.[0]?.relationships?.tracks
})
// Tracks are in relationships.tracks.data when using ?include=tracks
const albumData = response.data?.[0]
const tracksData = albumData?.relationships?.tracks?.data
if (tracksData?.length) {
console.log(`Found ${tracksData.length} tracks for album "${attributes.name}"`)
// Process all tracks
tracks = tracksData
.filter((item: any) => item.type === 'songs')
.map((track: any) => ({
name: track.attributes?.name || 'Unknown',
previewUrl: track.attributes?.previews?.[0]?.url,
durationMs: track.attributes?.durationInMillis
}))
// Log track details
tracks.forEach((track, index) => {
console.log(
`Track ${index + 1}: ${track.name} - Preview: ${track.previewUrl ? 'Yes' : 'No'} - Duration: ${track.durationMs}ms`
)
})
// Find the first track with a preview if we don't have one
if (!previewUrl) {
for (const track of tracksData) {
if (track.type === 'songs' && track.attributes?.previews?.[0]?.url) {
previewUrl = track.attributes.previews[0].url
console.log(`Using preview URL from track "${track.attributes.name}"`)
break
}
}
}
} else {
console.log('No tracks found in album response')
}
} catch (error) {
console.error('Failed to fetch album tracks:', error)
}
}
return {
appleMusicId: appleMusicAlbum.id,
highResArtwork: attributes.artwork
? attributes.artwork.url.replace('{w}x{h}', '3000x3000')
: undefined,
previewUrl,
// Store additional metadata for future use
genres: attributes.genreNames,
releaseDate: attributes.releaseDate,
trackCount: attributes.trackCount,
recordLabel: attributes.recordLabel,
copyright: attributes.copyright,
editorialNotes: attributes.editorialNotes,
isComplete: attributes.isComplete,
tracks
}
}

View file

@ -0,0 +1,118 @@
import redis from '../../routes/api/redis-client'
interface RateLimitState {
failureCount: number
lastFailureTime: number
backoffUntil: number
}
export class ApiRateLimiter {
private readonly maxRetries = 3
private readonly baseBackoffMs = 1000 // Start with 1 second
private readonly maxBackoffMs = 60000 // Max 1 minute
private readonly resetTimeMs = 300000 // Reset after 5 minutes of success
constructor(private readonly apiName: string) {}
private getStateKey(identifier: string): string {
return `ratelimit:${this.apiName}:${identifier}`
}
private getFailureKey(identifier: string): string {
return `failure:${this.apiName}:${identifier}`
}
async shouldBlock(identifier: string): Promise<boolean> {
// Check if this specific request has failed too many times
const failureKey = this.getFailureKey(identifier)
const failureCount = await redis.get(failureKey)
if (failureCount && parseInt(failureCount) >= this.maxRetries) {
console.log(`Blocking request for ${identifier} - too many failures`)
return true
}
// Check if we're in backoff period
const stateKey = this.getStateKey(this.apiName)
const stateJson = await redis.get(stateKey)
if (!stateJson) return false
const state: RateLimitState = JSON.parse(stateJson)
const now = Date.now()
if (now < state.backoffUntil) {
console.log(
`API ${this.apiName} in backoff until ${new Date(state.backoffUntil).toISOString()}`
)
return true
}
// Reset state if enough time has passed
if (now - state.lastFailureTime > this.resetTimeMs) {
await redis.del(stateKey)
}
return false
}
async recordSuccess(identifier: string): Promise<void> {
// Clear failure count for this specific identifier
const failureKey = this.getFailureKey(identifier)
await redis.del(failureKey)
}
async recordFailure(identifier: string, is429: boolean = false): Promise<void> {
// Record failure for specific identifier
const failureKey = this.getFailureKey(identifier)
const currentCount = await redis.get(failureKey)
const newCount = (currentCount ? parseInt(currentCount) : 0) + 1
// Set with 24 hour expiry
await redis.set(failureKey, newCount.toString(), 'EX', 86400)
if (is429) {
// Handle rate limiting with exponential backoff
const stateKey = this.getStateKey(this.apiName)
const stateJson = await redis.get(stateKey)
let state: RateLimitState
if (stateJson) {
state = JSON.parse(stateJson)
state.failureCount++
} else {
state = {
failureCount: 1,
lastFailureTime: Date.now(),
backoffUntil: Date.now()
}
}
// Calculate exponential backoff
const backoffMs = Math.min(
this.baseBackoffMs * Math.pow(2, state.failureCount - 1),
this.maxBackoffMs
)
state.lastFailureTime = Date.now()
state.backoffUntil = Date.now() + backoffMs
// Store state with expiry
await redis.set(stateKey, JSON.stringify(state), 'EX', 3600)
console.log(`API ${this.apiName} rate limited - backing off for ${backoffMs}ms`)
}
}
async cacheNotFound(identifier: string, ttl: number = 3600): Promise<void> {
// Cache "not found" results to prevent repeated lookups
const notFoundKey = `notfound:${this.apiName}:${identifier}`
await redis.set(notFoundKey, '1', 'EX', ttl)
}
async isNotFoundCached(identifier: string): Promise<boolean> {
const notFoundKey = `notfound:${this.apiName}:${identifier}`
const cached = await redis.get(notFoundKey)
return cached === '1'
}
}

View file

@ -0,0 +1,46 @@
import { writable, derived, get } from 'svelte/store'
interface AudioPreviewState {
currentAudio: HTMLAudioElement | null
currentAlbumId: string | null
isPlaying: boolean
}
function createAudioPreviewStore() {
const { subscribe, set, update } = writable<AudioPreviewState>({
currentAudio: null,
currentAlbumId: null,
isPlaying: false
})
return {
subscribe,
play: (audio: HTMLAudioElement, albumId: string) => {
update((state) => {
// Pause any currently playing audio
if (state.currentAudio && state.currentAudio !== audio) {
state.currentAudio.pause()
}
return {
currentAudio: audio,
currentAlbumId: albumId,
isPlaying: true
}
})
},
stop: () => {
update((state) => {
if (state.currentAudio) {
state.currentAudio.pause()
}
return {
currentAudio: null,
currentAlbumId: null,
isPlaying: false
}
})
}
}
}
export const audioPreview = createAudioPreviewStore()

View file

@ -0,0 +1,137 @@
import { writable, derived, get, type Readable } from 'svelte/store'
import { browser } from '$app/environment'
interface NowPlayingUpdate {
albumName: string
artistName: string
isNowPlaying: boolean
nowPlayingTrack?: string
}
interface NowPlayingState {
connected: boolean
updates: Map<string, NowPlayingUpdate>
lastUpdate: Date | null
}
function createNowPlayingStream() {
const { subscribe, set, update } = writable<NowPlayingState>({
connected: false,
updates: new Map(),
lastUpdate: null
})
let eventSource: EventSource | null = null
let reconnectTimeout: NodeJS.Timeout | null = null
let reconnectAttempts = 0
function connect() {
if (!browser || eventSource?.readyState === EventSource.OPEN) return
// Clean up existing connection
disconnect()
eventSource = new EventSource('/api/lastfm/stream')
eventSource.addEventListener('connected', () => {
console.log('Now Playing stream connected')
reconnectAttempts = 0
update((state) => ({ ...state, connected: true }))
})
eventSource.addEventListener('nowplaying', (event) => {
try {
const updates: NowPlayingUpdate[] = JSON.parse(event.data)
update((state) => {
const newUpdates = new Map(state.updates)
for (const album of updates) {
const key = `${album.artistName}:${album.albumName}`
newUpdates.set(key, album)
}
return {
...state,
updates: newUpdates,
lastUpdate: new Date()
}
})
} catch (error) {
console.error('Error parsing now playing update:', error)
}
})
eventSource.addEventListener('heartbeat', () => {
// Heartbeat received, connection is healthy
})
eventSource.addEventListener('error', (error) => {
console.error('Now Playing stream error:', error)
update((state) => ({ ...state, connected: false }))
// Attempt to reconnect with exponential backoff
if (reconnectAttempts < 5) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
reconnectTimeout = setTimeout(() => {
reconnectAttempts++
connect()
}, delay)
}
})
eventSource.addEventListener('open', () => {
update((state) => ({ ...state, connected: true }))
})
}
function disconnect() {
if (eventSource) {
eventSource.close()
eventSource = null
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
update((state) => ({ ...state, connected: false }))
}
// Auto-connect in browser
if (browser) {
connect()
// Reconnect on visibility change
document.addEventListener('visibilitychange', () => {
const currentState = get({ subscribe })
if (document.visibilityState === 'visible' && !currentState.connected) {
connect()
}
})
}
return {
subscribe,
connect,
disconnect,
// Helper to check if a specific album is now playing
isAlbumPlaying: derived({ subscribe }, ($state) => (artistName: string, albumName: string) => {
const key = `${artistName}:${albumName}`
const update = $state.updates.get(key)
return update
? {
isNowPlaying: update.isNowPlaying,
nowPlayingTrack: update.nowPlayingTrack
}
: null
}) as Readable<
(
artistName: string,
albumName: string
) => { isNowPlaying: boolean; nowPlayingTrack?: string } | null
>
}
}
export const nowPlayingStream = createNowPlayingStream()

View file

@ -0,0 +1,135 @@
export interface AppleMusicArtwork {
width: number
height: number
url: string
bgColor?: string
textColor1?: string
textColor2?: string
textColor3?: string
textColor4?: string
}
export interface AppleMusicPreview {
url: string
}
export interface AppleMusicAttributes {
artistName: string
artwork: AppleMusicArtwork
contentRating?: string
copyright?: string
editorialNotes?: {
short?: string
standard?: string
}
genreNames: string[]
isCompilation: boolean
isComplete: boolean
isMasteredForItunes: boolean
isSingle: boolean
name: string
playParams?: {
id: string
kind: string
}
previews?: AppleMusicPreview[]
recordLabel?: string
releaseDate: string
trackCount: number
upc?: string
url: string
}
export interface AppleMusicTrackAttributes {
albumName: string
artistName: string
artwork: AppleMusicArtwork
composerName?: string
contentRating?: string
discNumber: number
durationInMillis: number
genreNames: string[]
hasLyrics: boolean
isrc?: string
name: string
playParams?: {
id: string
kind: string
}
previews: AppleMusicPreview[]
releaseDate: string
trackNumber: number
url: string
}
export interface AppleMusicRelationships {
artists?: {
data: AppleMusicResource<any>[]
href?: string
next?: string
}
tracks?: {
data: AppleMusicResource<AppleMusicTrackAttributes>[]
href?: string
next?: string
}
}
export interface AppleMusicResource<T> {
id: string
type: string
href: string
attributes: T
relationships?: AppleMusicRelationships
}
export interface AppleMusicAlbum extends AppleMusicResource<AppleMusicAttributes> {
type: 'albums'
}
export interface AppleMusicTrack extends AppleMusicResource<AppleMusicTrackAttributes> {
type: 'songs'
}
export interface AppleMusicSearchResponse {
results: {
albums?: {
data: AppleMusicAlbum[]
href?: string
next?: string
}
songs?: {
data: AppleMusicTrack[]
href?: string
next?: string
}
}
}
export interface AppleMusicErrorResponse {
errors: Array<{
id: string
title: string
detail: string
status: string
code: string
}>
}
// Type guards
export function isAppleMusicError(response: any): response is AppleMusicErrorResponse {
return response && 'errors' in response && Array.isArray(response.errors)
}
export function isAppleMusicAlbum(resource: any): resource is AppleMusicAlbum {
return resource && resource.type === 'albums' && 'attributes' in resource
}
export function isAppleMusicTrack(resource: any): resource is AppleMusicTrack {
return resource && resource.type === 'songs' && 'attributes' in resource
}
// Helper function to get high-resolution artwork URL
export function getArtworkUrl(artwork: AppleMusicArtwork, size: number = 3000): string {
return artwork.url.replace('{w}x{h}', `${size}x${size}`)
}

View file

@ -26,6 +26,25 @@ export interface Album {
url: string
rank: number
images: AlbumImages
isNowPlaying?: boolean
nowPlayingTrack?: string
appleMusicData?: {
appleMusicId?: string
highResArtwork?: string
previewUrl?: string
genres?: string[]
releaseDate?: string
trackCount?: number
recordLabel?: string
copyright?: string
editorialNotes?: any
isComplete?: boolean
tracks?: Array<{
name: string
previewUrl?: string
durationMs?: number
}>
}
}
export interface WeeklyAlbumChart {

View file

@ -164,7 +164,7 @@ function renderTiptapContent(doc: any): string {
const image = node.attrs?.image || ''
const favicon = node.attrs?.favicon || ''
const siteName = node.attrs?.siteName || ''
// Helper to get domain from URL
const getDomain = (url: string) => {
try {
@ -174,14 +174,14 @@ function renderTiptapContent(doc: any): string {
return ''
}
}
// Helper to extract YouTube video ID
const getYouTubeVideoId = (url: string): string | null => {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match && match[1]) {
@ -190,33 +190,34 @@ function renderTiptapContent(doc: any): string {
}
return null
}
// Check if it's a YouTube URL
const isYouTube = /(?:youtube\.com|youtu\.be)/.test(url)
const videoId = isYouTube ? getYouTubeVideoId(url) : null
if (isYouTube && videoId) {
// Render YouTube embed
let embedHtml = '<div class="url-embed-rendered url-embed-youtube">'
embedHtml += '<div class="youtube-embed-wrapper">'
embedHtml += `<iframe src="https://www.youtube.com/embed/${videoId}" `
embedHtml += 'frameborder="0" '
embedHtml += 'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" '
embedHtml +=
'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" '
embedHtml += 'allowfullscreen>'
embedHtml += '</iframe>'
embedHtml += '</div>'
embedHtml += '</div>'
return embedHtml
}
// Regular URL embed for non-YouTube links
let embedHtml = '<div class="url-embed-rendered">'
embedHtml += `<a href="${url}" target="_blank" rel="noopener noreferrer" class="url-embed-link">`
if (image) {
embedHtml += `<div class="url-embed-image"><img src="${image}" alt="${title || 'Link preview'}" /></div>`
}
embedHtml += '<div class="url-embed-text">'
embedHtml += '<div class="url-embed-meta">'
if (favicon) {
@ -224,19 +225,19 @@ function renderTiptapContent(doc: any): string {
}
embedHtml += `<span class="url-embed-domain">${siteName || getDomain(url)}</span>`
embedHtml += '</div>'
if (title) {
embedHtml += `<h3 class="url-embed-title">${title}</h3>`
}
if (description) {
embedHtml += `<p class="url-embed-description">${description}</p>`
}
embedHtml += '</div>'
embedHtml += '</a>'
embedHtml += '</div>'
return embedHtml
}

View file

@ -21,7 +21,7 @@ export function extractEmbeds(content: any): ExtractedEmbed[] {
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match && match[1]) {
@ -36,7 +36,7 @@ export function extractEmbeds(content: any): ExtractedEmbed[] {
if (node.type === 'urlEmbed' && node.attrs?.url) {
const url = node.attrs.url
const isYouTube = /(?:youtube\.com|youtu\.be)/.test(url)
if (isYouTube) {
const videoId = getYouTubeVideoId(url)
if (videoId) {
@ -76,4 +76,4 @@ export function extractEmbeds(content: any): ExtractedEmbed[] {
}
return embeds
}
}

View file

@ -4,6 +4,7 @@
import MentionList from '$components/MentionList.svelte'
import Page from '$components/Page.svelte'
import RecentAlbums from '$components/RecentAlbums.svelte'
import StreamStatus from '$components/StreamStatus.svelte'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
@ -101,6 +102,8 @@
{/if}
</section> -->
</Page>
<StreamStatus />
</section>
<style lang="scss">

View file

@ -1,26 +1,33 @@
import 'dotenv/config'
import { LastClient } from '@musicorum/lastfm'
import {
searchItunes,
ItunesSearchOptions,
ItunesMedia,
ItunesEntityMusic
} from 'node-itunes-search'
import type { RequestHandler } from './$types'
import type { Album, AlbumImages } from '$lib/types/lastfm'
import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common'
import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client'
import redis from '../redis-client'
const LASTFM_API_KEY = process.env.LASTFM_API_KEY
const USERNAME = 'jedmund'
const ALBUM_LIMIT = 10
// Store last played tracks with timestamps
interface TrackPlayInfo {
albumName: string
trackName: string
scrobbleTime: Date
durationMs?: number
}
let recentTracks: TrackPlayInfo[] = []
export const GET: RequestHandler = async ({ url }) => {
const client = new LastClient(LASTFM_API_KEY || '')
const testMode = url.searchParams.get('test') === 'nowplaying'
try {
// const albums = await getWeeklyAlbumChart(client, USERNAME)
const albums = await getRecentAlbums(client, USERNAME, ALBUM_LIMIT)
const albums = await getRecentAlbums(client, USERNAME, ALBUM_LIMIT, testMode)
// console.log(albums)
const enrichedAlbums = await Promise.all(
albums.slice(0, ALBUM_LIMIT).map(async (album) => {
@ -37,9 +44,9 @@ export const GET: RequestHandler = async ({ url }) => {
)
const validAlbums = enrichedAlbums.filter((album) => album !== null)
const albumsWithItunesArt = await addItunesArtToAlbums(validAlbums)
const albumsWithAppleMusicData = await addAppleMusicDataToAlbums(validAlbums)
return new Response(JSON.stringify({ albums: albumsWithItunesArt }), {
return new Response(JSON.stringify({ albums: albumsWithAppleMusicData }), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
@ -62,16 +69,63 @@ async function getWeeklyAlbumChart(client: LastClient, username: string): Promis
async function getRecentAlbums(
client: LastClient,
username: string,
limit: number
limit: number,
testMode: boolean = false
): Promise<Album[]> {
const recentTracks = await client.user.getRecentTracks(username, { limit: 50, extended: true })
const uniqueAlbums = new Map<string, Album>()
// Check cache for recent tracks
const cacheKey = `lastfm:recent:${username}`
const cached = await redis.get(cacheKey)
let recentTracksResponse
if (cached) {
console.log('Using cached Last.fm recent tracks')
recentTracksResponse = JSON.parse(cached)
// Convert date strings back to Date objects
if (recentTracksResponse.tracks) {
recentTracksResponse.tracks = recentTracksResponse.tracks.map((track: any) => ({
...track,
date: track.date ? new Date(track.date) : undefined
}))
}
} else {
console.log('Fetching fresh Last.fm recent tracks')
recentTracksResponse = await client.user.getRecentTracks(username, {
limit: 50,
extended: true
})
// Cache for 30 seconds - reasonable for "recent" data
await redis.set(cacheKey, JSON.stringify(recentTracksResponse), 'EX', 30)
}
const uniqueAlbums = new Map<string, Album>()
let nowPlayingTrack: string | undefined
let isFirstAlbum = true
// Clear old tracks and collect new track play information
recentTracks = []
for (const track of recentTracksResponse.tracks) {
// Store track play information for now playing calculation
if (track.date) {
recentTracks.push({
albumName: track.album.name,
trackName: track.name,
scrobbleTime: track.date
})
}
for (const track of recentTracks.tracks) {
if (uniqueAlbums.size >= limit) break
// Check if this is the currently playing track
if (track.nowPlaying && !nowPlayingTrack) {
nowPlayingTrack = track.name
}
const albumKey = `${track.album.mbid || track.album.name}`
if (!uniqueAlbums.has(albumKey)) {
// For testing: mark first album as now playing
const isNowPlaying = testMode && isFirstAlbum ? true : track.nowPlaying || false
uniqueAlbums.set(albumKey, {
name: track.album.name,
artist: {
@ -82,7 +136,19 @@ async function getRecentAlbums(
images: transformImages(track.images),
mbid: track.album.mbid || '',
url: track.url,
rank: uniqueAlbums.size + 1
rank: uniqueAlbums.size + 1,
// Mark if this album contains the now playing track
isNowPlaying: isNowPlaying,
nowPlayingTrack: isNowPlaying ? track.name : undefined
})
isFirstAlbum = false
} else if (track.nowPlaying) {
// If album already exists but this track is now playing, update it
const existingAlbum = uniqueAlbums.get(albumKey)!
uniqueAlbums.set(albumKey, {
...existingAlbum,
isNowPlaying: true,
nowPlayingTrack: track.name
})
}
}
@ -91,7 +157,26 @@ async function getRecentAlbums(
}
async function enrichAlbumWithInfo(client: LastClient, album: Album): Promise<Album> {
// Check cache for album info
const cacheKey = `lastfm:albuminfo:${album.artist.name}:${album.name}`
const cached = await redis.get(cacheKey)
if (cached) {
console.log(`Using cached album info for "${album.name}"`)
const albumInfo = JSON.parse(cached)
return {
...album,
url: albumInfo?.url || '',
images: transformImages(albumInfo?.images || [])
}
}
console.log(`Fetching fresh album info for "${album.name}"`)
const albumInfo = await client.album.getInfo(album.name, album.artist.name)
// Cache for 1 hour - album info rarely changes
await redis.set(cacheKey, JSON.stringify(albumInfo), 'EX', 3600)
return {
...album,
url: albumInfo?.url || '',
@ -99,39 +184,66 @@ async function enrichAlbumWithInfo(client: LastClient, album: Album): Promise<Al
}
}
async function addItunesArtToAlbums(albums: Album[]): Promise<Album[]> {
return Promise.all(albums.map(searchItunesForAlbum))
async function addAppleMusicDataToAlbums(albums: Album[]): Promise<Album[]> {
return Promise.all(albums.map(searchAppleMusicForAlbum))
}
async function searchItunesForAlbum(album: Album): Promise<Album> {
const itunesResult = await searchItunesStores(album.name, album.artist.name)
async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
try {
// Check cache first
const cacheKey = `apple:album:${album.artist.name}:${album.name}`
const cached = await redis.get(cacheKey)
if (itunesResult && itunesResult.results.length > 0) {
const firstResult = itunesResult.results[0]
album.images.itunes = firstResult.artworkUrl100.replace('100x100', '600x600')
}
return album
}
async function searchItunesStores(albumName: string, artistName: string): Promise<any | null> {
const stores = ['JP', 'US']
for (const store of stores) {
const encodedTerm = encodeURIComponent(`${albumName} ${artistName}`)
const result = await searchItunes(
new ItunesSearchOptions({
term: encodedTerm,
country: store,
media: ItunesMedia.Music,
entity: ItunesEntityMusic.Album,
limit: 1
if (cached) {
const cachedData = JSON.parse(cached)
console.log(`Using cached data for "${album.name}":`, {
hasPreview: !!cachedData.previewUrl,
trackCount: cachedData.tracks?.length || 0
})
)
if (result.resultCount > 0) return result
// Check if this album is currently playing based on track durations
const updatedAlbum = checkNowPlaying(album, cachedData)
return {
...updatedAlbum,
images: {
...album.images,
itunes: cachedData.highResArtwork || album.images.itunes
},
appleMusicData: cachedData
}
}
// Search Apple Music
const appleMusicAlbum = await findAlbum(album.artist.name, album.name)
if (appleMusicAlbum) {
const transformedData = await transformAlbumData(appleMusicAlbum)
// Cache the result for 24 hours
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
// Check if this album is currently playing based on track durations
const updatedAlbum = checkNowPlaying(album, transformedData)
return {
...updatedAlbum,
images: {
...album.images,
itunes: transformedData.highResArtwork || album.images.itunes
},
appleMusicData: transformedData
}
}
} catch (error) {
console.error(
`Failed to fetch Apple Music data for "${album.name}" by "${album.artist.name}":`,
error
)
}
return null
// Return album unchanged if Apple Music search fails
return album
}
function transformImages(images: LastfmImage[]): AlbumImages {
@ -166,3 +278,48 @@ function transformImages(images: LastfmImage[]): AlbumImages {
return transformedImages
}
function checkNowPlaying(album: Album, appleMusicData: any): Album {
// Don't override if already marked as now playing by Last.fm
if (album.isNowPlaying) {
return album
}
// Check if any recent track from this album could still be playing
const now = new Date()
const SCROBBLE_LAG = 3 * 60 * 1000 // 3 minutes in milliseconds
for (const trackInfo of recentTracks) {
if (trackInfo.albumName !== album.name) continue
// Find the track duration from Apple Music data
const trackData = appleMusicData.tracks?.find(
(t: any) => t.name.toLowerCase() === trackInfo.trackName.toLowerCase()
)
if (trackData?.durationMs) {
// Calculate when the track should end (scrobble time + duration + lag)
const trackEndTime = new Date(
trackInfo.scrobbleTime.getTime() + trackData.durationMs + SCROBBLE_LAG
)
// If current time is before track end time, it's likely still playing
if (now < trackEndTime) {
console.log(`Detected now playing: "${trackInfo.trackName}" from "${album.name}"`, {
scrobbleTime: trackInfo.scrobbleTime,
durationMs: trackData.durationMs,
estimatedEndTime: trackEndTime,
currentTime: now
})
return {
...album,
isNowPlaying: true,
nowPlayingTrack: trackInfo.trackName
}
}
}
}
return album
}

View file

@ -0,0 +1,304 @@
import { LastClient } from '@musicorum/lastfm'
import type { RequestHandler } from './$types'
import type { Album } from '$lib/types/lastfm'
import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client'
import redis from '../../redis-client'
const LASTFM_API_KEY = process.env.LASTFM_API_KEY
const USERNAME = 'jedmund'
const UPDATE_INTERVAL = 30000 // 30 seconds to reduce API load
interface NowPlayingUpdate {
albumName: string
artistName: string
isNowPlaying: boolean
nowPlayingTrack?: string
}
// Store recent tracks for duration-based detection
interface TrackPlayInfo {
albumName: string
trackName: string
scrobbleTime: Date
durationMs?: number
}
let recentTracks: TrackPlayInfo[] = []
export const GET: RequestHandler = async ({ request }) => {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const client = new LastClient(LASTFM_API_KEY || '')
let lastNowPlayingState: Map<string, { isPlaying: boolean; track?: string }> = new Map()
let intervalId: NodeJS.Timeout | null = null
let isClosed = false
// Send initial connection message
try {
controller.enqueue(encoder.encode('event: connected\ndata: {}\n\n'))
} catch (e) {
console.error('Failed to send initial message:', e)
return
}
const checkForUpdates = async () => {
if (isClosed) {
if (intervalId) {
clearInterval(intervalId)
}
return
}
try {
const nowPlayingAlbums = await getNowPlayingAlbums(client)
const updates: NowPlayingUpdate[] = []
const currentAlbums = new Set<string>()
// Check for changes
for (const album of nowPlayingAlbums) {
const key = `${album.artistName}:${album.albumName}`
currentAlbums.add(key)
const lastState = lastNowPlayingState.get(key)
const wasPlaying = lastState?.isPlaying || false
const lastTrack = lastState?.track
// Update if playing status changed OR if the track changed
if (
album.isNowPlaying !== wasPlaying ||
(album.isNowPlaying && album.nowPlayingTrack !== lastTrack)
) {
updates.push(album)
console.log(
`Update for ${album.albumName}: playing=${album.isNowPlaying}, track=${album.nowPlayingTrack}`
)
}
lastNowPlayingState.set(key, {
isPlaying: album.isNowPlaying,
track: album.nowPlayingTrack
})
}
// Check for albums that were in the list but aren't anymore (stopped playing)
for (const [key, state] of lastNowPlayingState.entries()) {
if (!currentAlbums.has(key) && state.isPlaying) {
const [artistName, albumName] = key.split(':')
updates.push({
albumName,
artistName,
isNowPlaying: false
})
console.log(`Album no longer in recent: ${albumName}`)
lastNowPlayingState.delete(key)
}
}
// Check if controller is still open before sending
if (!isClosed) {
// Send updates if any
if (updates.length > 0) {
const data = JSON.stringify(updates)
try {
controller.enqueue(encoder.encode(`event: nowplaying\ndata: ${data}\n\n`))
} catch (e) {
// This is expected when client disconnects
isClosed = true
}
}
// Send heartbeat to keep connection alive
try {
controller.enqueue(encoder.encode('event: heartbeat\ndata: {}\n\n'))
} catch (e) {
// This is expected when client disconnects
isClosed = true
}
}
} catch (error) {
console.error('Error checking for updates:', error)
}
}
// Initial check
await checkForUpdates()
// Set up interval
intervalId = setInterval(checkForUpdates, UPDATE_INTERVAL)
// Handle client disconnect
request.signal.addEventListener('abort', () => {
isClosed = true
if (intervalId) {
clearInterval(intervalId)
}
try {
controller.close()
} catch (e) {
// Already closed
}
})
},
cancel() {
// Cleanup when stream is cancelled
console.log('SSE stream cancelled')
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no' // Disable Nginx buffering
}
})
}
async function getNowPlayingAlbums(client: LastClient): Promise<NowPlayingUpdate[]> {
// Check cache for recent tracks
const cacheKey = `lastfm:recent:${USERNAME}`
const cached = await redis.get(cacheKey)
let recentTracksResponse
if (cached) {
console.log('Using cached Last.fm recent tracks for streaming')
recentTracksResponse = JSON.parse(cached)
// Convert date strings back to Date objects
if (recentTracksResponse.tracks) {
recentTracksResponse.tracks = recentTracksResponse.tracks.map((track: any) => ({
...track,
date: track.date ? new Date(track.date) : undefined
}))
}
} else {
console.log('Fetching fresh Last.fm recent tracks for streaming')
recentTracksResponse = await client.user.getRecentTracks(USERNAME, {
limit: 50,
extended: true
})
// Cache for 30 seconds - reasonable for "recent" data
await redis.set(cacheKey, JSON.stringify(recentTracksResponse), 'EX', 30)
}
const albums: Map<string, NowPlayingUpdate> = new Map()
// Clear old tracks and collect new track play information
recentTracks = []
for (const track of recentTracksResponse.tracks) {
// Store track play information
if (track.date) {
recentTracks.push({
albumName: track.album.name,
trackName: track.name,
scrobbleTime: track.date
})
}
const albumKey = `${track.artist.name}:${track.album.name}`
if (!albums.has(albumKey)) {
const album: NowPlayingUpdate = {
albumName: track.album.name,
artistName: track.artist.name,
isNowPlaying: track.nowPlaying || false,
nowPlayingTrack: track.nowPlaying ? track.name : undefined
}
// If not marked as now playing by Last.fm, check with duration-based detection
if (!album.isNowPlaying) {
const updatedStatus = await checkNowPlayingWithDuration(album.albumName, album.artistName)
if (updatedStatus) {
album.isNowPlaying = updatedStatus.isNowPlaying
album.nowPlayingTrack = updatedStatus.nowPlayingTrack
}
}
albums.set(albumKey, album)
}
}
return Array.from(albums.values())
}
async function checkNowPlayingWithDuration(
albumName: string,
artistName: string
): Promise<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null> {
try {
// Check cache for Apple Music data
const cacheKey = `apple:album:${artistName}:${albumName}`
const cached = await redis.get(cacheKey)
if (!cached) {
// Try to fetch from Apple Music if not cached
const appleMusicAlbum = await findAlbum(artistName, albumName)
if (!appleMusicAlbum) return null
const transformedData = await transformAlbumData(appleMusicAlbum)
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
return checkWithTracks(albumName, transformedData.tracks)
}
const appleMusicData = JSON.parse(cached)
return checkWithTracks(albumName, appleMusicData.tracks)
} catch (error) {
console.error(`Error checking duration for ${albumName}:`, error)
return null
}
}
function checkWithTracks(
albumName: string,
tracks?: Array<{ name: string; durationMs?: number }>
): { isNowPlaying: boolean; nowPlayingTrack?: string } | null {
if (!tracks) return null
const now = new Date()
const SCROBBLE_LAG = 3 * 60 * 1000 // 3 minutes
// Clean up old tracks first
recentTracks = recentTracks.filter((track) => {
// Keep tracks from last hour only
const hourAgo = new Date(now.getTime() - 60 * 60 * 1000)
return track.scrobbleTime > hourAgo
})
// Find the most recent track from this album
let mostRecentTrack: TrackPlayInfo | null = null
for (const trackInfo of recentTracks) {
if (trackInfo.albumName === albumName) {
if (!mostRecentTrack || trackInfo.scrobbleTime > mostRecentTrack.scrobbleTime) {
mostRecentTrack = trackInfo
}
}
}
if (mostRecentTrack) {
const trackData = tracks.find(
(t) => t.name.toLowerCase() === mostRecentTrack.trackName.toLowerCase()
)
if (trackData?.durationMs) {
const trackEndTime = new Date(
mostRecentTrack.scrobbleTime.getTime() + trackData.durationMs + SCROBBLE_LAG
)
if (now < trackEndTime) {
console.log(
`Track "${mostRecentTrack.trackName}" is still playing (ends at ${trackEndTime.toLocaleTimeString()})`
)
return {
isNowPlaying: true,
nowPlayingTrack: mostRecentTrack.trackName
}
}
}
}
return { isNowPlaying: false }
}

View file

@ -13,10 +13,10 @@ export const GET: RequestHandler = async ({ url }) => {
try {
// Check cache first (unless force refresh is requested)
const cacheKey = `og-metadata:${targetUrl}`
if (!forceRefresh) {
const cached = await redis.get(cacheKey)
if (cached) {
console.log(`Cache hit for ${targetUrl}`)
return json(JSON.parse(cached))
@ -33,7 +33,7 @@ export const GET: RequestHandler = async ({ url }) => {
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
]
let videoId = null
for (const pattern of patterns) {
const match = targetUrl.match(pattern)
@ -183,7 +183,7 @@ export const POST: RequestHandler = async ({ request }) => {
// Check cache first - using same cache key format
const cacheKey = `og-metadata:${targetUrl}`
const cached = await redis.get(cacheKey)
if (cached) {
console.log(`Cache hit for ${targetUrl} (POST)`)
const ogData = JSON.parse(cached)
@ -208,7 +208,7 @@ export const POST: RequestHandler = async ({ request }) => {
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
]
let videoId = null
for (const pattern of patterns) {
const match = targetUrl.match(pattern)