2
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

@@ -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
if (collections.includes('ai.syui.at.link')) {
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
}
// 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
export interface LinkItem {
service: 'github' | 'youtube' | 'x'

View File

@@ -1,5 +1,5 @@
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
rkey?: string
service?: string
@@ -75,6 +75,12 @@ export function parseRoute(): Route {
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
const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/edit$/)
if (chatEditMatch) {
@@ -133,6 +139,8 @@ export function navigate(route: Route): void {
path = `/@${route.handle}/at/chat/${route.rkey}/edit`
} else if (route.type === 'link' && route.handle) {
path = `/@${route.handle}/at/link`
} else if (route.type === 'vrm' && route.handle) {
path = `/@${route.handle}/at/vrm`
}
window.history.pushState({}, '', path)

View File

@@ -1,7 +1,8 @@
import './styles/main.css'
import './styles/card.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 { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost, updateChat, updateLinks, getAgent } from './lib/auth'
import { validateRecord } from './lib/lexicon'
@@ -16,6 +17,7 @@ import { renderChatListPage, renderChatThreadPage, renderChatEditForm } from './
import { renderCardPage } from './components/card'
import { renderRsePage } from './components/rse'
import { renderLinkPage, renderLinkButtons } from './components/link'
import { renderVrmPage, setupVrmPage } from './components/vrm'
import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate'
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 += `<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') {
// Chat list page - show threads started by this user
if (!config.bot) {
@@ -451,6 +459,11 @@ async function render(route: Route): Promise<void> {
setupRecordMerge()
}
// Setup VRM page audio controls
if (route.type === 'vrm') {
setupVrmPage()
}
// Setup card migration button
if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) {
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;
}
}