add vrm
This commit is contained in:
@@ -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
170
src/web/components/vrm.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
240
src/web/styles/vrm.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user