diff --git a/.github/workflows/cf-pages.yml b/.github/workflows/cf-pages.yml index eb02aae..08d98c7 100644 --- a/.github/workflows/cf-pages.yml +++ b/.github/workflows/cf-pages.yml @@ -31,4 +31,5 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: '4' command: pages deploy dist --project-name=${{ secrets.CLOUDFLARE_PROJECT_NAME }} diff --git a/lexicons/ai.syui.vrm.json b/lexicons/ai.syui.vrm.json new file mode 100644 index 0000000..2d2ae05 --- /dev/null +++ b/lexicons/ai.syui.vrm.json @@ -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" } + } + } + } + } +} diff --git a/public/at/did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/index.json b/public/at/did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/index.json index 1f8ee6c..336db09 100644 --- a/public/at/did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/index.json +++ b/public/at/did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/index.json @@ -72,23 +72,6 @@ "45yvfu6vqnmv4", "3r7eq4inllheg", "3pxakq4gd75sv", - "3mg3rw27axw3b", - "3mg3rq6ij6437", - "3mg3rhouk6f35", - "3mg3rapmgsl33", - "3mg3r6s5p7z2z", - "3mg3r3gri7r2x", - "3mg3qqz3dot2v", - "3mg3qpeskxq2t", - "3mg3qfakfy62r", - "3mg3qckhoty2p", - "3mg3q7kmfuh2n", - "3mg3q6rk64a2l", - "3mg3phljj7a2j", - "3mg3pcyma2q2h", - "3mg3pc6z6ul2f", - "3mg3opcvv2m2d", - "3mg3fsxyzmc23", "3mftdjlirgw2j", "3mftdemshj52h", "3mftdbillyb2f", @@ -138,11 +121,21 @@ "2ks46vomw4s2i", "2ivbc5b4um5bu", "255k3bskheo6j", - "3mg3gbuc2al25", - "3mg3gdhs3rx27", - "3mg3gey7tos2b", - "3mg3ntffa7z23", - "3mg3nwdq3px25", - "3mg3nwu2d7l27", - "3mg3nzxv5cz2b" + "3mg3fsxyzmc23", + "3mg3opcvv2m2d", + "3mg3pc6z6ul2f", + "3mg3pcyma2q2h", + "3mg3phljj7a2j", + "3mg3q6rk64a2l", + "3mg3q7kmfuh2n", + "3mg3qckhoty2p", + "3mg3qfakfy62r", + "3mg3qpeskxq2t", + "3mg3qqz3dot2v", + "3mg3r3gri7r2x", + "3mg3r6s5p7z2z", + "3mg3rapmgsl33", + "3mg3rhouk6f35", + "3mg3rq6ij6437", + "3mg3rw27axw3b" ] \ No newline at end of file diff --git a/public/at/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/index.json b/public/at/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/index.json index f314102..37851cc 100644 --- a/public/at/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/index.json +++ b/public/at/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/index.json @@ -122,13 +122,6 @@ "3mftdemsfzl2g", "3mftdjliqoc2i", "3mg3fsxyyss22", - "3mg3gbubzqr24", - "3mg3gdhs3d326", - "3mg3gey7t322a", - "3mg3ntff62p22", - "3mg3nwdq3a524", - "3mg3nwu2cu726", - "3mg3nzxv3ro2a", "3mg3opcvul42c", "3mg3pc6z5zp2e", "3mg3pcym7pc2g", diff --git a/public/icon/crown.svg b/public/icon/crown.svg new file mode 100644 index 0000000..b049586 --- /dev/null +++ b/public/icon/crown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icon/play.svg b/public/icon/play.svg new file mode 100644 index 0000000..7294c77 --- /dev/null +++ b/public/icon/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/service/ai.svg b/public/service/ai.svg new file mode 100644 index 0000000..e3e634a --- /dev/null +++ b/public/service/ai.svg @@ -0,0 +1,19 @@ + + + diff --git a/public/service/ai.syui.app.png b/public/service/ai.syui.app.png new file mode 100644 index 0000000..e42996e Binary files /dev/null and b/public/service/ai.syui.app.png differ diff --git a/public/service/ai.syui.at.png b/public/service/ai.syui.at.png index b9c9eca..b90f209 100644 Binary files a/public/service/ai.syui.at.png and b/public/service/ai.syui.at.png differ diff --git a/public/service/ai.syui.card.png b/public/service/ai.syui.card.png index f5243c3..332a6a6 100644 Binary files a/public/service/ai.syui.card.png and b/public/service/ai.syui.card.png differ diff --git a/public/service/ai.syui.gpt.png b/public/service/ai.syui.gpt.png new file mode 100644 index 0000000..25a18a1 Binary files /dev/null and b/public/service/ai.syui.gpt.png differ diff --git a/public/service/ai.syui.log.png b/public/service/ai.syui.log.png new file mode 100644 index 0000000..95983e9 Binary files /dev/null and b/public/service/ai.syui.log.png differ diff --git a/public/service/ai.syui.os.png b/public/service/ai.syui.os.png new file mode 100644 index 0000000..37d8ea1 Binary files /dev/null and b/public/service/ai.syui.os.png differ diff --git a/public/service/ai.syui.shell.png b/public/service/ai.syui.shell.png new file mode 100644 index 0000000..9d06200 Binary files /dev/null and b/public/service/ai.syui.shell.png differ diff --git a/public/service/ai.syui.ue.png b/public/service/ai.syui.ue.png new file mode 100644 index 0000000..02ad33b Binary files /dev/null and b/public/service/ai.syui.ue.png differ diff --git a/public/service/ai.syui.vrm.png b/public/service/ai.syui.vrm.png new file mode 100644 index 0000000..8336591 Binary files /dev/null and b/public/service/ai.syui.vrm.png differ diff --git a/public/service/ai.syui.vrma.png b/public/service/ai.syui.vrma.png new file mode 100644 index 0000000..04c829a Binary files /dev/null and b/public/service/ai.syui.vrma.png differ diff --git a/public/service/ai_black.svg b/public/service/ai_black.svg new file mode 100644 index 0000000..c4b4e42 --- /dev/null +++ b/public/service/ai_black.svg @@ -0,0 +1,19 @@ + + + diff --git a/public/service/ai_white.svg b/public/service/ai_white.svg new file mode 100644 index 0000000..3f02909 --- /dev/null +++ b/public/service/ai_white.svg @@ -0,0 +1,19 @@ + + + diff --git a/public/service/syui.svg b/public/service/syui.svg new file mode 100644 index 0000000..5813502 --- /dev/null +++ b/public/service/syui.svg @@ -0,0 +1,67 @@ + + + + +syui + + + + + + + diff --git a/src/web/components/profile.ts b/src/web/components/profile.ts index 5703433..6172c55 100644 --- a/src/web/components/profile.ts +++ b/src/web/components/profile.ts @@ -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({ diff --git a/src/web/components/vrm.ts b/src/web/components/vrm.ts new file mode 100644 index 0000000..6bc8ffb --- /dev/null +++ b/src/web/components/vrm.ts @@ -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 = { + 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 `` +} + +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 ` +
+
+ ${renderCrownSvg(tier.color)} +
+
+ ${filename} + ${item.cp} +
+
+ + +
+
+ ` +} + +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 ` +
+
+

VRM

+ json +
+

No VRM data found for @${handle}

+
+ ` + } + + 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 ` +
+
+
+
+ ${items.length} + Items +
+
+ ${totalCp} + CP +
+
+
+
+ json +
+
${itemsHtml}
+
+ ` +} + +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 `` +} + +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 = { '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 + } + }) + }) +} diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts index 5419b55..40c22a5 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -669,6 +669,52 @@ export async function getRse(did: string): Promise { 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 { + 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' diff --git a/src/web/lib/router.ts b/src/web/lib/router.ts index 1031034..95e94da 100644 --- a/src/web/lib/router.ts +++ b/src/web/lib/router.ts @@ -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) diff --git a/src/web/main.ts b/src/web/main.ts index 80e122d..08ce412 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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 { html += `
${renderLinkPage(links, handle, isOwner)}
` html += `` + } else if (route.type === 'vrm') { + // VRM page + const vrmData = await getVrm(did) + html += `
${renderVrmPage(vrmData, handle)}
` + html += `` + } 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 { 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( diff --git a/src/web/styles/vrm.css b/src/web/styles/vrm.css new file mode 100644 index 0000000..fac309f --- /dev/null +++ b/src/web/styles/vrm.css @@ -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: ''; + 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; + } +}