diff --git a/public/service/ai.syui.card.old.png b/public/service/ai.syui.card.old.png new file mode 100644 index 0000000..ed4f62e Binary files /dev/null and b/public/service/ai.syui.card.old.png differ diff --git a/src/web/components/card-migrate.ts b/src/web/components/card-migrate.ts new file mode 100644 index 0000000..3a12b97 --- /dev/null +++ b/src/web/components/card-migrate.ts @@ -0,0 +1,239 @@ +// Card migration component - migrate from api.syui.ai to ATProto + +import { getOldApiUserByDid, getOldApiCards, getCardOldRecordKey, generateChecksum, type OldApiUser, type OldApiCard } from '../lib/api' +import { saveMigratedCardData, isLoggedIn, getLoggedInDid } from '../lib/auth' + +export interface MigrationState { + loading: boolean + oldApiUser: OldApiUser | null + oldApiCards: OldApiCard[] + hasMigrated: boolean + migratedRkey: string | null + error: string | null +} + +// Check migration status for a user +export async function checkMigrationStatus(did: string): Promise { + const state: MigrationState = { + loading: true, + oldApiUser: null, + oldApiCards: [], + hasMigrated: false, + migratedRkey: null, + error: null + } + + try { + // Check if already migrated + state.migratedRkey = await getCardOldRecordKey(did) + state.hasMigrated = state.migratedRkey !== null + + // Check if user exists in api.syui.ai + state.oldApiUser = await getOldApiUserByDid(did) + + if (state.oldApiUser) { + // Load cards + state.oldApiCards = await getOldApiCards(state.oldApiUser.id) + } + } catch (err) { + state.error = String(err) + } + + state.loading = false + return state +} + +// Convert datetime to ISO UTC format +function toUtcDatetime(dateStr: string): string { + try { + return new Date(dateStr).toISOString() + } catch { + return new Date().toISOString() + } +} + +// Perform migration +export async function performMigration(user: OldApiUser, cards: OldApiCard[]): Promise { + const checksum = generateChecksum(user, cards) + + // Convert user data (only required + used fields, matching lexicon types) + // Note: ATProto doesn't support float, so planet is converted to integer + const userData = { + username: user.username, + did: user.did, + aiten: Math.floor(user.aiten), + fav: Math.floor(user.fav), + coin: Math.floor(user.coin), + planet: Math.floor(user.planet), + createdAt: toUtcDatetime(user.created_at), + updatedAt: toUtcDatetime(user.updated_at), + } + + // Convert card data (only required + used fields) + const cardData = cards.map(c => ({ + id: c.id, + card: c.card, + cp: c.cp, + status: c.status || 'normal', + skill: c.skill || 'normal', + createdAt: toUtcDatetime(c.created_at), + })) + + const result = await saveMigratedCardData(userData, cardData, checksum) + return result !== null +} + +// Render migration icon for profile (shown when user has api.syui.ai account) +export function renderMigrationIcon(handle: string, hasOldApi: boolean, hasMigrated: boolean): string { + if (!hasOldApi) return '' + + const icon = hasMigrated ? '/service/ai.syui.card.png' : '/service/ai.syui.card.old.png' + const title = hasMigrated ? 'Card (Migrated)' : 'Card Migration Available' + + return ` + + Card Migration + + ` +} + +// Convert status to rarity +function statusToRare(status: string): number { + switch (status) { + case 'super': return 3 // unique + case 'shiny': return 2 // shiny (assumed from skill or special status) + case 'first': return 1 // rare + default: return 0 // normal + } +} + +// Render migration page (simplified) +export function renderMigrationPage( + state: MigrationState, + handle: string, + isOwner: boolean +): string { + const { oldApiUser, oldApiCards, hasMigrated, migratedRkey, error } = state + const jsonUrl = migratedRkey + ? `/@${handle}/at/collection/ai.syui.card.old/${migratedRkey}` + : `/@${handle}/at/collection/ai.syui.card.old` + + if (error) { + return ` +
+
Error: ${error}
+
+ ` + } + + if (!oldApiUser) { + return ` +
+

No api.syui.ai account found

+
+ ` + } + + // Button or migrated status + let buttonHtml = '' + if (hasMigrated) { + buttonHtml = `✓ migrated` + } else if (isOwner && isLoggedIn()) { + buttonHtml = `` + } + + // Card grid (same style as /card page) + const cardGroups = new Map() + for (const card of oldApiCards) { + const existing = cardGroups.get(card.card) + const rare = statusToRare(card.status) + if (existing) { + existing.count++ + if (card.cp > existing.maxCp) existing.maxCp = card.cp + if (rare > existing.rare) existing.rare = rare + } else { + cardGroups.set(card.card, { card, count: 1, maxCp: card.cp, rare }) + } + } + + const sortedGroups = Array.from(cardGroups.values()) + .sort((a, b) => a.card.card - b.card.card) + + const cardsHtml = sortedGroups.map(({ card, count, maxCp, rare }) => { + const rarityClass = rare === 3 ? 'unique' : rare === 2 ? 'shiny' : rare === 1 ? 'rare' : '' + const effectsHtml = rarityClass ? ` +
+
+ ` : '' + const countBadge = count > 1 ? `x${count}` : '' + + return ` +
+
+
+ Card ${card.card} +
+ ${effectsHtml} + ${countBadge} +
+
+ ${maxCp} +
+
+ ` + }).join('') + + return ` +
+
+ api.syui.ai → ai.syui.card.old + ${buttonHtml} + json +
+
${cardsHtml}
+
+ ` +} + +// Setup migration button handler +export function setupMigrationButton( + oldApiUser: OldApiUser, + oldApiCards: OldApiCard[], + onSuccess: () => void +): void { + const btn = document.getElementById('migrate-btn') + if (!btn) return + + btn.addEventListener('click', async (e) => { + e.preventDefault() + e.stopPropagation() + + const loggedInDid = getLoggedInDid() + if (!loggedInDid || loggedInDid !== oldApiUser.did) { + alert('DID mismatch. Please login with the correct account.') + return + } + + if (!confirm(`Migrate ${oldApiCards.length} cards to ATProto?`)) { + return + } + + btn.textContent = 'Migrating...' + ;(btn as HTMLButtonElement).disabled = true + + try { + const success = await performMigration(oldApiUser, oldApiCards) + if (success) { + alert('Migration successful!') + onSuccess() + } else { + alert('Migration failed.') + } + } catch (err) { + alert('Migration error: ' + err) + } + + btn.textContent = 'Migrate to ATProto' + ;(btn as HTMLButtonElement).disabled = false + }) +} diff --git a/src/web/components/profile.ts b/src/web/components/profile.ts index 67436b4..a868d83 100644 --- a/src/web/components/profile.ts +++ b/src/web/components/profile.ts @@ -9,8 +9,14 @@ export interface ServiceLink { collection: string } +// Migration state for api.syui.ai users +export interface MigrationInfo { + hasOldApi: boolean + hasMigrated: boolean +} + // Get available services based on user's collections -export function getServiceLinks(handle: string, collections: string[]): ServiceLink[] { +export function getServiceLinks(handle: string, collections: string[], migration?: MigrationInfo): ServiceLink[] { const services: ServiceLink[] = [] if (collections.includes('ai.syui.card.user')) { @@ -22,6 +28,16 @@ export function getServiceLinks(handle: string, collections: string[]): ServiceL }) } + // Add migration link if user has api.syui.ai account + if (migration?.hasOldApi) { + services.push({ + name: 'Card (old)', + icon: '/service/ai.syui.card.old.png', + url: `/@${handle}/at/card-old`, + collection: 'ai.syui.card.old' + }) + } + return services } @@ -31,7 +47,8 @@ export async function renderProfile( handle: string, webUrl?: string, localOnly = false, - collections: string[] = [] + collections: string[] = [], + migration?: MigrationInfo ): Promise { // Local mode: sync, no API call. Remote mode: async with API call const avatarUrl = localOnly @@ -51,10 +68,10 @@ export async function renderProfile( ? `${escapeHtml(displayName)}` : `
` - // Service icons (show for users with matching collections) + // Service icons (show for users with matching collections or migration available) let serviceIconsHtml = '' - if (collections.length > 0) { - const services = getServiceLinks(handle, collections) + if (collections.length > 0 || migration?.hasOldApi) { + const services = getServiceLinks(handle, collections, migration) if (services.length > 0) { const iconsHtml = services.map(s => ` diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts index d0131f9..e22f59e 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -429,6 +429,147 @@ export async function getChatMessages( ) } +// ============================================ +// api.syui.ai migration functions +// ============================================ + +const API_SYUI_AI = 'https://api.syui.ai' + +// Old API user type +export interface OldApiUser { + id: number + username: string + did: string + member: boolean + book: boolean + manga: boolean + badge: boolean + bsky: boolean + mastodon: boolean + delete: boolean + handle: boolean + created_at: string + updated_at: string + raid_at: string + server_at: string + egg_at: string + luck: number + luck_at: string + like: number + like_rank: number + like_at: string + fav: number + ten: boolean + ten_su: number + ten_kai: number + aiten: number + ten_card: string + ten_delete: string + ten_post: string + ten_get: string + ten_at: string + next: string + room: number + model: boolean + model_at: string + model_attack: number + model_limit: number + model_skill: number + model_mode: number + model_critical: number + model_critical_d: number + game: boolean + game_test: boolean + game_end: boolean + game_account: boolean + game_lv: number + game_exp: number + game_story: number + game_limit: boolean + coin: number + coin_open: boolean + coin_at: string + planet: number + planet_at: string + login: boolean + login_at: string + location_x: number + location_y: number + location_z: number + location_n: number +} + +// Old API card type +export interface OldApiCard { + id: number + card: number + skill: string + status: string + cp: number + url: string + count: number + author: string + created_at: string +} + +// Check if user exists in api.syui.ai by DID +export async function getOldApiUserByDid(did: string): Promise { + try { + const res = await fetch(`${API_SYUI_AI}/users?itemsPerPage=2500`) + if (!res.ok) return null + const users: OldApiUser[] = await res.json() + return users.find(u => u.did === did) || null + } catch { + return null + } +} + +// Get user's cards from api.syui.ai +export async function getOldApiCards(userId: number): Promise { + try { + const res = await fetch(`${API_SYUI_AI}/users/${userId}/card?itemsPerPage=5000`) + if (!res.ok) return [] + return res.json() + } catch { + return [] + } +} + +// Check if ai.syui.card.old record exists and return the rkey +export async function getCardOldRecordKey(did: string): Promise { + const pds = await getPds(did) + if (!pds) return null + + try { + const host = pds.replace('https://', '') + const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=ai.syui.card.old&limit=1` + const res = await fetch(url) + if (!res.ok) return null + const data = await res.json() + if (data.records && data.records.length > 0) { + // Extract rkey from URI: at://did/collection/rkey + const uri = data.records[0].uri as string + const rkey = uri.split('/').pop() + return rkey || null + } + return null + } catch { + return null + } +} + +// Check if ai.syui.card.old record exists +export async function hasCardOldRecord(did: string): Promise { + const rkey = await getCardOldRecordKey(did) + return rkey !== null +} + +// Generate checksum for verification +export function generateChecksum(user: OldApiUser, cards: OldApiCard[]): string { + const sum = user.id + user.aiten + user.fav + cards.reduce((acc, c) => acc + c.id + c.cp + c.card, 0) + return btoa(String(sum)) +} + // Get user's card collection (ai.syui.card.user) export async function getCards( did: string, diff --git a/src/web/lib/auth.ts b/src/web/lib/auth.ts index 27aec8b..e4f5ac3 100644 --- a/src/web/lib/auth.ts +++ b/src/web/lib/auth.ts @@ -272,6 +272,56 @@ export async function updatePost( } } +// Save migrated card data to ai.syui.card.old +export async function saveMigratedCardData( + user: { + username: string + did: string + aiten: number + planet: number + fav: number + coin: number + createdAt: string + updatedAt: string + }, + cards: { + id: number + card: number + cp: number + status: string + skill: string + createdAt: string + }[], + checksum: string +): Promise<{ uri: string; cid: string } | null> { + if (!agent) return null + + const collection = 'ai.syui.card.old' + const rkey = 'self' + + try { + const record = { + $type: collection, + user, + cards, + checksum, + migratedAt: new Date().toISOString(), + } + + const result = await agent.com.atproto.repo.putRecord({ + repo: agent.assertDid, + collection, + rkey, + record, + }) + + return { uri: result.data.uri, cid: result.data.cid } + } catch (err) { + console.error('Save migrated card data error:', err) + throw err + } +} + // Delete record export async function deleteRecord( collection: string, diff --git a/src/web/lib/router.ts b/src/web/lib/router.ts index b385c6a..6891bfe 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' | 'card' + type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'card' | 'card-old' handle?: string rkey?: string service?: string @@ -57,6 +57,12 @@ export function parseRoute(): Route { return { type: 'card', handle: cardMatch[1] } } + // Card migration page: /@handle/at/card-old + const cardOldMatch = path.match(/^\/@([^/]+)\/at\/card-old\/?$/) + if (cardOldMatch) { + return { type: 'card-old', handle: cardOldMatch[1] } + } + // Chat thread: /@handle/at/chat/{rkey} const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/) if (chatThreadMatch) { @@ -99,6 +105,8 @@ export function navigate(route: Route): void { path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}` } else if (route.type === 'card' && route.handle) { path = `/@${route.handle}/at/card` + } else if (route.type === 'card-old' && route.handle) { + path = `/@${route.handle}/at/card-old` } else if (route.type === 'chat' && route.handle) { path = `/@${route.handle}/at/chat` } else if (route.type === 'chat-thread' && route.handle && route.rkey) { diff --git a/src/web/main.ts b/src/web/main.ts index 8a3c278..a3916cf 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -1,6 +1,7 @@ import './styles/main.css' import './styles/card.css' -import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards } from './lib/api' +import './styles/card-migrate.css' +import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards, getOldApiUserByDid, hasCardOldRecord } from './lib/api' import { parseRoute, onRouteChange, navigate, type Route } from './lib/router' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth' import { validateRecord } from './lib/lexicon' @@ -13,6 +14,7 @@ import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/ import { renderFooter } from './components/footer' import { renderChatListPage, renderChatThreadPage } from './components/chat' import { renderCardPage } from './components/card' +import { checkMigrationStatus, renderMigrationPage, setupMigrationButton } from './components/card-migrate' import { showLoading, hideLoading } from './components/loading' const app = document.getElementById('app')! @@ -173,13 +175,21 @@ async function render(route: Route): Promise { const loggedInDid = getLoggedInDid() const isOwner = isLoggedIn() && loggedInDid === did + // Check migration status (api.syui.ai -> ATProto) + const [oldApiUser, hasMigrated] = await Promise.all([ + getOldApiUserByDid(did), + hasCardOldRecord(did) + ]) + const migration = oldApiUser ? { hasOldApi: true, hasMigrated } : undefined + // Profile section if (profile) { - html += await renderProfile(did, profile, handle, webUrl, localOnly, collections) + html += await renderProfile(did, profile, handle, webUrl, localOnly, collections, migration) } // Content section based on route type let currentRecord: { uri: string; cid: string; value: unknown } | null = null + let cardMigrationState: Awaited> | null = null if (route.type === 'record' && route.collection && route.rkey) { // AT-Browser: Single record view @@ -238,6 +248,12 @@ async function render(route: Route): Promise { html += `
${renderCardPage(cards, handle, cardCollection)}
` html += `
` + } else if (route.type === 'card-old') { + // Card migration page + cardMigrationState = await checkMigrationStatus(did) + html += `
${renderMigrationPage(cardMigrationState, handle, isOwner)}
` + html += `` + } else if (route.type === 'chat') { // Chat list page - show threads started by this user if (!config.bot) { @@ -365,6 +381,15 @@ async function render(route: Route): Promise { } } + // Setup card migration button + if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) { + setupMigrationButton( + cardMigrationState.oldApiUser, + cardMigrationState.oldApiCards, + () => render(parseRoute()) // Refresh on success + ) + } + } catch (error) { console.error('Render error:', error) app.innerHTML = ` diff --git a/src/web/styles/card-migrate.css b/src/web/styles/card-migrate.css new file mode 100644 index 0000000..96741d7 --- /dev/null +++ b/src/web/styles/card-migrate.css @@ -0,0 +1,44 @@ +/* Card Migration Page Styles */ + +.migrate-title { + font-weight: 500; + color: var(--text-secondary, #666); +} + +.migrated-badge { + color: #00a060; + font-size: 0.9em; +} + +.migrate-btn { + background: var(--btn-color, #0066cc); + color: white; + border: none; + padding: 6px 16px; + border-radius: 4px; + font-size: 0.9em; + cursor: pointer; + transition: opacity 0.2s; +} + +.migrate-btn:hover { + opacity: 0.9; +} + +.migrate-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.no-data { + color: var(--text-secondary, #666); + text-align: center; + padding: 32px; +} + +/* Dark mode */ +@media (prefers-color-scheme: dark) { + .migrate-title { + color: var(--text-secondary, #aaa); + } +}