add ai.syui.card.old
This commit is contained in:
239
src/web/components/card-migrate.ts
Normal file
239
src/web/components/card-migrate.ts
Normal file
@@ -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<MigrationState> {
|
||||
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<boolean> {
|
||||
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 `
|
||||
<a href="/@${handle}/at/card-old" class="service-icon-link" title="${title}">
|
||||
<img src="${icon}" alt="Card Migration" class="service-icon ${hasMigrated ? '' : 'migration-available'}" />
|
||||
</a>
|
||||
`
|
||||
}
|
||||
|
||||
// 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 `
|
||||
<div class="card-migrate-page">
|
||||
<div class="error">Error: ${error}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
if (!oldApiUser) {
|
||||
return `
|
||||
<div class="card-migrate-page">
|
||||
<p class="no-data">No api.syui.ai account found</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Button or migrated status
|
||||
let buttonHtml = ''
|
||||
if (hasMigrated) {
|
||||
buttonHtml = `<span class="migrated-badge">✓ migrated</span>`
|
||||
} else if (isOwner && isLoggedIn()) {
|
||||
buttonHtml = `<button id="migrate-btn" class="migrate-btn">Migrate</button>`
|
||||
}
|
||||
|
||||
// Card grid (same style as /card page)
|
||||
const cardGroups = new Map<number, { card: OldApiCard, count: number, maxCp: number, rare: number }>()
|
||||
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 ? `
|
||||
<div class="card-status pattern-${rarityClass}"></div>
|
||||
<div class="card-status color-${rarityClass}"></div>
|
||||
` : ''
|
||||
const countBadge = count > 1 ? `<span class="card-count">x${count}</span>` : ''
|
||||
|
||||
return `
|
||||
<div class="card-item">
|
||||
<div class="card-wrapper">
|
||||
<div class="card-reflection">
|
||||
<img src="/card/${card.card}.webp" alt="Card ${card.card}" loading="lazy" />
|
||||
</div>
|
||||
${effectsHtml}
|
||||
${countBadge}
|
||||
</div>
|
||||
<div class="card-detail">
|
||||
<span class="card-cp">${maxCp}</span>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `
|
||||
<div class="card-page">
|
||||
<div class="card-header">
|
||||
<span class="migrate-title">api.syui.ai → ai.syui.card.old</span>
|
||||
${buttonHtml}
|
||||
<a href="${jsonUrl}" class="json-btn">json</a>
|
||||
</div>
|
||||
<div class="card-grid">${cardsHtml}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
}
|
||||
@@ -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<string> {
|
||||
// Local mode: sync, no API call. Remote mode: async with API call
|
||||
const avatarUrl = localOnly
|
||||
@@ -51,10 +68,10 @@ export async function renderProfile(
|
||||
? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">`
|
||||
: `<div class="profile-avatar-placeholder"></div>`
|
||||
|
||||
// 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 => `
|
||||
<a href="${s.url}" class="service-icon-link" title="${s.name}">
|
||||
|
||||
@@ -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<OldApiUser | null> {
|
||||
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<OldApiCard[]> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void> {
|
||||
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<ReturnType<typeof checkMigrationStatus>> | 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<void> {
|
||||
html += `<div id="content">${renderCardPage(cards, handle, cardCollection)}</div>`
|
||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||
|
||||
} else if (route.type === 'card-old') {
|
||||
// Card migration page
|
||||
cardMigrationState = await checkMigrationStatus(did)
|
||||
html += `<div id="content">${renderMigrationPage(cardMigrationState, handle, isOwner)}</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) {
|
||||
@@ -365,6 +381,15 @@ async function render(route: Route): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = `
|
||||
|
||||
44
src/web/styles/card-migrate.css
Normal file
44
src/web/styles/card-migrate.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user