1
0
This commit is contained in:
2026-03-08 16:23:07 +09:00
parent b64785350b
commit 595766c617
26 changed files with 665 additions and 33 deletions

View File

@@ -31,4 +31,5 @@ jobs:
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
wranglerVersion: '4'
command: pages deploy dist --project-name=${{ secrets.CLOUDFLARE_PROJECT_NAME }} command: pages deploy dist --project-name=${{ secrets.CLOUDFLARE_PROJECT_NAME }}

32
lexicons/ai.syui.vrm.json Normal file
View File

@@ -0,0 +1,32 @@
{
"$type": "com.atproto.lexicon.schema",
"lexicon": 1,
"id": "ai.syui.vrm",
"defs": {
"main": {
"type": "record",
"key": "literal:self",
"description": "VRM game score collection",
"record": {
"type": "object",
"required": ["item", "createdAt", "updatedAt"],
"properties": {
"item": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "cp", "cid"],
"properties": {
"id": { "type": "integer", "description": "Crown type: 1=gold, 2=silver, 3=bronze" },
"cp": { "type": "integer", "description": "Score at acquisition" },
"cid": { "type": "string", "description": "Unique instance ID" }
}
}
},
"createdAt": { "type": "string", "format": "datetime" },
"updatedAt": { "type": "string", "format": "datetime" }
}
}
}
}
}

View File

@@ -72,23 +72,6 @@
"45yvfu6vqnmv4", "45yvfu6vqnmv4",
"3r7eq4inllheg", "3r7eq4inllheg",
"3pxakq4gd75sv", "3pxakq4gd75sv",
"3mg3rw27axw3b",
"3mg3rq6ij6437",
"3mg3rhouk6f35",
"3mg3rapmgsl33",
"3mg3r6s5p7z2z",
"3mg3r3gri7r2x",
"3mg3qqz3dot2v",
"3mg3qpeskxq2t",
"3mg3qfakfy62r",
"3mg3qckhoty2p",
"3mg3q7kmfuh2n",
"3mg3q6rk64a2l",
"3mg3phljj7a2j",
"3mg3pcyma2q2h",
"3mg3pc6z6ul2f",
"3mg3opcvv2m2d",
"3mg3fsxyzmc23",
"3mftdjlirgw2j", "3mftdjlirgw2j",
"3mftdemshj52h", "3mftdemshj52h",
"3mftdbillyb2f", "3mftdbillyb2f",
@@ -138,11 +121,21 @@
"2ks46vomw4s2i", "2ks46vomw4s2i",
"2ivbc5b4um5bu", "2ivbc5b4um5bu",
"255k3bskheo6j", "255k3bskheo6j",
"3mg3gbuc2al25", "3mg3fsxyzmc23",
"3mg3gdhs3rx27", "3mg3opcvv2m2d",
"3mg3gey7tos2b", "3mg3pc6z6ul2f",
"3mg3ntffa7z23", "3mg3pcyma2q2h",
"3mg3nwdq3px25", "3mg3phljj7a2j",
"3mg3nwu2d7l27", "3mg3q6rk64a2l",
"3mg3nzxv5cz2b" "3mg3q7kmfuh2n",
"3mg3qckhoty2p",
"3mg3qfakfy62r",
"3mg3qpeskxq2t",
"3mg3qqz3dot2v",
"3mg3r3gri7r2x",
"3mg3r6s5p7z2z",
"3mg3rapmgsl33",
"3mg3rhouk6f35",
"3mg3rq6ij6437",
"3mg3rw27axw3b"
] ]

View File

@@ -122,13 +122,6 @@
"3mftdemsfzl2g", "3mftdemsfzl2g",
"3mftdjliqoc2i", "3mftdjliqoc2i",
"3mg3fsxyyss22", "3mg3fsxyyss22",
"3mg3gbubzqr24",
"3mg3gdhs3d326",
"3mg3gey7t322a",
"3mg3ntff62p22",
"3mg3nwdq3a524",
"3mg3nwu2cu726",
"3mg3nzxv3ro2a",
"3mg3opcvul42c", "3mg3opcvul42c",
"3mg3pc6z5zp2e", "3mg3pc6z5zp2e",
"3mg3pcym7pc2g", "3mg3pcym7pc2g",

1
public/icon/crown.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M345 151.2C354.2 143.9 360 132.6 360 120C360 97.9 342.1 80 320 80C297.9 80 280 97.9 280 120C280 132.6 285.9 143.9 295 151.2L226.6 258.8C216.6 274.5 195.3 278.4 180.4 267.2L120.9 222.7C125.4 216.3 128 208.4 128 200C128 177.9 110.1 160 88 160C65.9 160 48 177.9 48 200C48 221.8 65.5 239.6 87.2 240L119.8 457.5C124.5 488.8 151.4 512 183.1 512L456.9 512C488.6 512 515.5 488.8 520.2 457.5L552.8 240C574.5 239.6 592 221.8 592 200C592 177.9 574.1 160 552 160C529.9 160 512 177.9 512 200C512 208.4 514.6 216.3 519.1 222.7L459.7 267.3C444.8 278.5 423.5 274.6 413.5 258.9L345 151.2z"/></svg>

After

Width:  |  Height:  |  Size: 802 B

1
public/icon/play.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.--><path d="M64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576C178.6 576 64 461.4 64 320zM252.3 211.1C244.7 215.3 240 223.4 240 232L240 408C240 416.7 244.7 424.7 252.3 428.9C259.9 433.1 269.1 433 276.6 428.4L420.6 340.4C427.7 336 432.1 328.3 432.1 319.9C432.1 311.5 427.7 303.8 420.6 299.4L276.6 211.4C269.2 206.9 259.9 206.7 252.3 210.9z"/></svg>

After

Width:  |  Height:  |  Size: 594 B

19
public/service/ai.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<path fill-rule="evenodd" fill="#F6E717" d="
M 619,232
L 512,7
L 405,232
A 300,300 0 0,0 216,559
L 75,765
L 323,745
A 300,300 0 0,0 701,745
L 949,765
L 808,559
A 300,300 0 0,0 619,232
Z
M 512,337
A 175,175 0 0,0 512,687
A 175,175 0 0,0 512,337
Z
"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<path fill-rule="evenodd" fill="#000000" d="
M 619,232
L 512,7
L 405,232
A 300,300 0 0,0 216,559
L 75,765
L 323,745
A 300,300 0 0,0 701,745
L 949,765
L 808,559
A 300,300 0 0,0 619,232
Z
M 512,337
A 175,175 0 0,0 512,687
A 175,175 0 0,0 512,337
Z
"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

View File

@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<path fill-rule="evenodd" fill="#FFFFFF" d="
M 619,232
L 512,7
L 405,232
A 300,300 0 0,0 216,559
L 75,765
L 323,745
A 300,300 0 0,0 701,745
L 949,765
L 808,559
A 300,300 0 0,0 619,232
Z
M 512,337
A 175,175 0 0,0 512,687
A 175,175 0 0,0 512,337
Z
"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

67
public/service/syui.svg Normal file
View File

@@ -0,0 +1,67 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
syui
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92
-98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22
-33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5
-13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247
-1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31
-14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83
-143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37
-28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121
-17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51
-112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4
-9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82
-123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34
-18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95
-62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17
-4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3
45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7
-7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10
23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15
72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52
32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12
24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106
27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534
10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13
200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60
-40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25
83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25
18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49
-3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0
-53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7
75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24
-46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96
-53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0
-7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85
-38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77
-25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91
-20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18
15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92
-113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115
301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46
89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23
15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z
m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7
-187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0
84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25
-32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17
-13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11
-14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49
-146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29
-104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48
22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0
10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40
16 57 18 38 52 41 99 11z" fill="#EF454A"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -42,6 +42,16 @@ export function getServiceLinks(handle: string, collections: string[]): ServiceL
}) })
} }
// VRM
if (collections.includes('ai.syui.vrm')) {
services.push({
name: 'VRM',
icon: '/service/ai.syui.vrm.png',
url: `/@${handle}/at/vrm`,
collection: 'ai.syui.vrm'
})
}
// Link // Link
if (collections.includes('ai.syui.at.link')) { if (collections.includes('ai.syui.at.link')) {
services.push({ services.push({

170
src/web/components/vrm.ts Normal file
View File

@@ -0,0 +1,170 @@
// VRM display component for ai.syui.vrm collection
export interface VrmItem {
id: number
cp: number
cid: string
}
export interface VrmCollection {
item: VrmItem[]
createdAt: string
updatedAt: string
}
interface VrmTier {
name: string
color: string
bgmUrl: string
}
const VRM_TIERS: Record<number, VrmTier> = {
1: { name: 'Gold', color: '#FFD700', bgmUrl: 'https://vrm.syui.ai/music/gold.mp3' },
2: { name: 'Silver', color: '#C0C0C0', bgmUrl: 'https://vrm.syui.ai/music/silver.mp3' },
3: { name: 'Bronze', color: '#CD7F32', bgmUrl: 'https://vrm.syui.ai/music/bronze.mp3' },
}
function renderCrownSvg(color: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" class="vrm-crown" fill="${color}"><path d="M345 151.2C354.2 143.9 360 132.6 360 120C360 97.9 342.1 80 320 80C297.9 80 280 97.9 280 120C280 132.6 285.9 143.9 295 151.2L226.6 258.8C216.6 274.5 195.3 278.4 180.4 267.2L120.9 222.7C125.4 216.3 128 208.4 128 200C128 177.9 110.1 160 88 160C65.9 160 48 177.9 48 200C48 221.8 65.5 239.6 87.2 240L119.8 457.5C124.5 488.8 151.4 512 183.1 512L456.9 512C488.6 512 515.5 488.8 520.2 457.5L552.8 240C574.5 239.6 592 221.8 592 200C592 177.9 574.1 160 552 160C529.9 160 512 177.9 512 200C512 208.4 514.6 216.3 519.1 222.7L459.7 267.3C444.8 278.5 423.5 274.6 413.5 258.9L345 151.2z"/></svg>`
}
function renderVrmItem(item: VrmItem): string {
const tier = VRM_TIERS[item.id] || VRM_TIERS[3]
const audioId = `vrm-audio-${item.id}-${item.cid}`
const filename = tier.bgmUrl.split('/').pop()?.replace('.mp3', '') || ''
return `
<div class="vrm-item" data-tier="${item.id}">
<div class="vrm-crown-wrapper">
${renderCrownSvg(tier.color)}
</div>
<div class="vrm-info">
<span class="vrm-track-name">${filename}</span>
<span class="vrm-cp">${item.cp}</span>
</div>
<div class="vrm-actions">
<button class="vrm-play-btn" data-audio-id="${audioId}" data-src="${tier.bgmUrl}" title="play">
${playIcon('currentColor')}
</button>
<audio id="${audioId}" preload="none"></audio>
</div>
</div>
`
}
export function renderVrmPage(
collection: VrmCollection | null,
handle: string
): string {
const jsonUrl = `/@${handle}/at/collection/ai.syui.vrm/self`
if (!collection || !collection.item || collection.item.length === 0) {
return `
<div class="vrm-page">
<div class="vrm-header">
<h2>VRM</h2>
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
<p class="no-cards">No VRM data found for @${handle}</p>
</div>
`
}
const items = [...collection.item].sort((a, b) => a.id - b.id)
const totalCp = items.reduce((sum, i) => sum + i.cp, 0)
const itemsHtml = items.map(item => renderVrmItem(item)).join('')
return `
<div class="vrm-page">
<div class="vrm-header">
<div class="vrm-stats">
<div class="stat">
<span class="stat-value">${items.length}</span>
<span class="stat-label">Items</span>
</div>
<div class="stat">
<span class="stat-value">${totalCp}</span>
<span class="stat-label">CP</span>
</div>
</div>
</div>
<div class="vrm-actions-header">
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
<div class="vrm-list">${itemsHtml}</div>
</div>
`
}
const PLAY_SVG_PATH = 'M64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576C178.6 576 64 461.4 64 320zM252.3 211.1C244.7 215.3 240 223.4 240 232L240 408C240 416.7 244.7 424.7 252.3 428.9C259.9 433.1 269.1 433 276.6 428.4L420.6 340.4C427.7 336 432.1 328.3 432.1 319.9C432.1 311.5 427.7 303.8 420.6 299.4L276.6 211.4C269.2 206.9 259.9 206.7 252.3 210.9z'
function playIcon(fill: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640" class="vrm-play-icon" fill="${fill}"><path d="${PLAY_SVG_PATH}"/></svg>`
}
function getVrmItem(button: HTMLButtonElement): HTMLElement | null {
return button.closest('.vrm-item')
}
function deactivateItem(btn: HTMLButtonElement): void {
btn.innerHTML = playIcon('currentColor')
const item = getVrmItem(btn)
if (item) item.classList.remove('vrm-active')
}
function activateItem(btn: HTMLButtonElement, color: string): void {
btn.innerHTML = playIcon(color)
const item = getVrmItem(btn)
if (item) {
item.style.setProperty('--vrm-tier-color', color)
item.classList.add('vrm-active')
}
}
export function setupVrmPage(): void {
let currentAudio: HTMLAudioElement | null = null
let currentBtn: HTMLButtonElement | null = null
document.querySelectorAll('.vrm-play-btn').forEach(btn => {
btn.addEventListener('click', () => {
const button = btn as HTMLButtonElement
const audioId = button.dataset.audioId!
const src = button.dataset.src!
const audio = document.getElementById(audioId) as HTMLAudioElement
const item = getVrmItem(button)
const tier = item?.dataset.tier || '3'
const tierColors: Record<string, string> = { '1': '#FFD700', '2': '#C0C0C0', '3': '#CD7F32' }
const color = tierColors[tier] || '#C0C0C0'
if (currentAudio && currentAudio === audio && !audio.paused) {
audio.pause()
audio.currentTime = 0
deactivateItem(button)
currentAudio = null
currentBtn = null
return
}
if (currentAudio && currentAudio !== audio) {
currentAudio.pause()
currentAudio.currentTime = 0
if (currentBtn) deactivateItem(currentBtn)
}
if (!audio.src || audio.src === '') {
audio.src = src
}
audio.play()
activateItem(button, color)
currentAudio = audio
currentBtn = button
audio.onended = () => {
deactivateItem(button)
currentAudio = null
currentBtn = null
}
})
})
}

View File

@@ -669,6 +669,52 @@ export async function getRse(did: string): Promise<RseCollection | null> {
return null return null
} }
// VRM collection type
export interface VrmItem {
id: number
cp: number
cid: string
}
export interface VrmCollection {
item: VrmItem[]
createdAt: string
updatedAt: string
}
// Get user's VRM collection (ai.syui.vrm)
export async function getVrm(did: string): Promise<VrmCollection | null> {
const collection = 'ai.syui.vrm'
// Try local first
try {
const res = await fetch(`/at/${did}/${collection}/self.json`)
if (res.ok && isJsonResponse(res)) {
const record = await res.json()
return record.value as VrmCollection
}
} catch {
// Try remote
}
// Remote fallback
const pds = await getPds(did)
if (!pds) return null
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self`
const res = await fetch(url)
if (res.ok) {
const record = await res.json()
return record.value as VrmCollection
}
} catch {
// Failed
}
return null
}
// Link item type // Link item type
export interface LinkItem { export interface LinkItem {
service: 'github' | 'youtube' | 'x' service: 'github' | 'youtube' | 'x'

View File

@@ -1,5 +1,5 @@
export interface Route { export interface Route {
type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'chat-edit' | 'card' | 'card-old' | 'rse' | 'link' type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'chat-edit' | 'card' | 'card-old' | 'rse' | 'link' | 'vrm'
handle?: string handle?: string
rkey?: string rkey?: string
service?: string service?: string
@@ -75,6 +75,12 @@ export function parseRoute(): Route {
return { type: 'link', handle: linkMatch[1] } return { type: 'link', handle: linkMatch[1] }
} }
// VRM page: /@handle/at/vrm
const vrmMatch = path.match(/^\/@([^/]+)\/at\/vrm\/?$/)
if (vrmMatch) {
return { type: 'vrm', handle: vrmMatch[1] }
}
// Chat edit: /@handle/at/chat/{rkey}/edit // Chat edit: /@handle/at/chat/{rkey}/edit
const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/edit$/) const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/edit$/)
if (chatEditMatch) { if (chatEditMatch) {
@@ -133,6 +139,8 @@ export function navigate(route: Route): void {
path = `/@${route.handle}/at/chat/${route.rkey}/edit` path = `/@${route.handle}/at/chat/${route.rkey}/edit`
} else if (route.type === 'link' && route.handle) { } else if (route.type === 'link' && route.handle) {
path = `/@${route.handle}/at/link` path = `/@${route.handle}/at/link`
} else if (route.type === 'vrm' && route.handle) {
path = `/@${route.handle}/at/vrm`
} }
window.history.pushState({}, '', path) window.history.pushState({}, '', path)

View File

@@ -1,7 +1,8 @@
import './styles/main.css' import './styles/main.css'
import './styles/card.css' import './styles/card.css'
import './styles/card-migrate.css' import './styles/card-migrate.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards, getCardAdmin, getRse, getRseAdmin, getLinks } from './lib/api' import './styles/vrm.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards, getCardAdmin, getRse, getRseAdmin, getLinks, getVrm } from './lib/api'
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router' import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost, updateChat, updateLinks, getAgent } from './lib/auth' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost, updateChat, updateLinks, getAgent } from './lib/auth'
import { validateRecord } from './lib/lexicon' import { validateRecord } from './lib/lexicon'
@@ -16,6 +17,7 @@ import { renderChatListPage, renderChatThreadPage, renderChatEditForm } from './
import { renderCardPage } from './components/card' import { renderCardPage } from './components/card'
import { renderRsePage } from './components/rse' import { renderRsePage } from './components/rse'
import { renderLinkPage, renderLinkButtons } from './components/link' import { renderLinkPage, renderLinkButtons } from './components/link'
import { renderVrmPage, setupVrmPage } from './components/vrm'
import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate' import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate'
import { showLoading, hideLoading } from './components/loading' import { showLoading, hideLoading } from './components/loading'
@@ -282,6 +284,12 @@ async function render(route: Route): Promise<void> {
html += `<div id="content">${renderLinkPage(links, handle, isOwner)}</div>` html += `<div id="content">${renderLinkPage(links, handle, isOwner)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>` html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'vrm') {
// VRM page
const vrmData = await getVrm(did)
html += `<div id="content">${renderVrmPage(vrmData, handle)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'chat') { } else if (route.type === 'chat') {
// Chat list page - show threads started by this user // Chat list page - show threads started by this user
if (!config.bot) { if (!config.bot) {
@@ -451,6 +459,11 @@ async function render(route: Route): Promise<void> {
setupRecordMerge() setupRecordMerge()
} }
// Setup VRM page audio controls
if (route.type === 'vrm') {
setupVrmPage()
}
// Setup card migration button // Setup card migration button
if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) { if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) {
setupMigrationButton( setupMigrationButton(

240
src/web/styles/vrm.css Normal file
View File

@@ -0,0 +1,240 @@
/* VRM Display Styles */
.vrm-page {
padding: 16px 0;
}
.vrm-header {
margin-bottom: 8px;
}
.vrm-actions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.vrm-stats {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
flex: 1;
}
.vrm-stats .stat {
display: flex;
flex-direction: column;
align-items: center;
min-width: 50px;
padding: 4px 8px;
border-radius: 4px;
}
.vrm-stats .stat-value {
font-size: 1.2em;
font-weight: bold;
color: var(--text-primary, #333);
}
.vrm-stats .stat-label {
font-size: 0.75em;
color: var(--text-secondary, #666);
}
/* VRM List */
.vrm-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.vrm-item {
position: relative;
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
border: 2px solid transparent;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.vrm-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Playing state - rotating border */
.vrm-item.vrm-active {
border-color: transparent;
background-origin: border-box;
background-clip: padding-box;
}
.vrm-item.vrm-active::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 10px;
padding: 2px;
background: conic-gradient(
from var(--vrm-border-angle, 0deg),
transparent 0%,
var(--vrm-tier-color, #C0C0C0) 25%,
transparent 50%,
var(--vrm-tier-color, #C0C0C0) 75%,
transparent 100%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: vrm-border-spin 3s linear infinite;
z-index: 0;
}
@keyframes vrm-border-spin {
to {
--vrm-border-angle: 360deg;
}
}
@property --vrm-border-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
/* Crown icon */
.vrm-crown-wrapper {
flex-shrink: 0;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.vrm-crown {
width: 40px;
height: 40px;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
/* Tier-specific glow */
.vrm-item[data-tier="1"] .vrm-crown {
filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.6));
}
.vrm-item[data-tier="2"] .vrm-crown {
filter: drop-shadow(0 0 8px rgba(192, 192, 192, 0.6));
}
.vrm-item[data-tier="3"] .vrm-crown {
filter: drop-shadow(0 0 8px rgba(205, 127, 50, 0.6));
}
/* Info */
.vrm-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.vrm-tier-name {
font-size: 1.1em;
font-weight: bold;
}
.vrm-track-name {
font-size: 0.85em;
color: var(--text-secondary, #666);
}
.vrm-cp {
font-size: 0.85em;
color: var(--text-secondary, #666);
}
/* Play button */
.vrm-actions {
flex-shrink: 0;
}
.vrm-play-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
transition: background 0.2s ease;
}
.vrm-play-btn:hover {
background: var(--bg-secondary, #f0f0f0);
}
.vrm-play-icon {
width: 28px;
height: 28px;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.vrm-play-btn:hover .vrm-play-icon {
opacity: 1;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.vrm-stats {
background: var(--bg-secondary, #2a2a2a);
}
.vrm-stats .stat-value {
color: var(--text-primary, #e0e0e0);
}
.vrm-item {
background: var(--bg-secondary, #2a2a2a);
}
.vrm-play-btn:hover {
background: var(--bg-secondary, #333);
}
.vrm-play-icon {
color: var(--text-primary, #e0e0e0);
}
}
/* Responsive */
@media (max-width: 480px) {
.vrm-item {
padding: 12px;
gap: 12px;
}
.vrm-crown-wrapper {
width: 36px;
height: 36px;
}
.vrm-crown {
width: 32px;
height: 32px;
}
.vrm-stats .stat-value {
font-size: 1em;
}
}