Compare commits
5 Commits
d19339ea0d
...
12422d275c
| Author | SHA1 | Date | |
|---|---|---|---|
|
12422d275c
|
|||
|
fa0b68d622
|
|||
|
5870541b96
|
|||
|
e5cccaca39
|
|||
|
c124492561
|
28
lexicons/ai.syui.card.admin.json
Normal file
28
lexicons/ai.syui.card.admin.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$type": "com.atproto.lexicon.schema",
|
||||||
|
"lexicon": 1,
|
||||||
|
"id": "ai.syui.card.admin",
|
||||||
|
"defs": {
|
||||||
|
"main": {
|
||||||
|
"type": "record",
|
||||||
|
"key": "literal:self",
|
||||||
|
"description": "Card game configuration (admin only)",
|
||||||
|
"record": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["card", "createdAt", "updatedAt"],
|
||||||
|
"properties": {
|
||||||
|
"card": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["pickup", "normal"],
|
||||||
|
"properties": {
|
||||||
|
"pickup": { "type": "integer", "description": "Pickup card ID" },
|
||||||
|
"normal": { "type": "integer", "description": "Normal card ID" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"createdAt": { "type": "string", "format": "datetime" },
|
||||||
|
"updatedAt": { "type": "string", "format": "datetime" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
lexicons/ai.syui.card.old.json
Normal file
53
lexicons/ai.syui.card.old.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"lexicon": 1,
|
||||||
|
"id": "ai.syui.card.old",
|
||||||
|
"defs": {
|
||||||
|
"main": {
|
||||||
|
"type": "record",
|
||||||
|
"key": "literal:self",
|
||||||
|
"description": "Migrated card data from api.syui.ai",
|
||||||
|
"record": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["user", "cards", "migratedAt"],
|
||||||
|
"properties": {
|
||||||
|
"user": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["username"],
|
||||||
|
"properties": {
|
||||||
|
"username": { "type": "string" },
|
||||||
|
"did": { "type": "string" },
|
||||||
|
"aiten": { "type": "integer" },
|
||||||
|
"fav": { "type": "integer" },
|
||||||
|
"coin": { "type": "integer" },
|
||||||
|
"planet": { "type": "integer" },
|
||||||
|
"createdAt": { "type": "string", "format": "datetime" },
|
||||||
|
"updatedAt": { "type": "string", "format": "datetime" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cards": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "card", "cp"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"card": { "type": "integer" },
|
||||||
|
"cp": { "type": "integer" },
|
||||||
|
"status": { "type": "string" },
|
||||||
|
"skill": { "type": "string" },
|
||||||
|
"createdAt": { "type": "string", "format": "datetime" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checksum": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"migratedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "datetime"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
lexicons/ai.syui.card.user.json
Normal file
33
lexicons/ai.syui.card.user.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"$type": "com.atproto.lexicon.schema",
|
||||||
|
"lexicon": 1,
|
||||||
|
"id": "ai.syui.card.user",
|
||||||
|
"defs": {
|
||||||
|
"main": {
|
||||||
|
"type": "record",
|
||||||
|
"key": "literal:self",
|
||||||
|
"description": "User card collection",
|
||||||
|
"record": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["card", "createdAt", "updatedAt"],
|
||||||
|
"properties": {
|
||||||
|
"card": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "cp", "rare", "cid"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer", "description": "Card type ID" },
|
||||||
|
"cp": { "type": "integer", "description": "Card power" },
|
||||||
|
"rare": { "type": "integer", "description": "Rarity level" },
|
||||||
|
"cid": { "type": "string", "description": "Unique card instance ID (TID format)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"createdAt": { "type": "string", "format": "datetime" },
|
||||||
|
"updatedAt": { "type": "string", "format": "datetime" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
lexicons/ai.syui.rse.admin.json
Normal file
77
lexicons/ai.syui.rse.admin.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"$type": "com.atproto.lexicon.schema",
|
||||||
|
"lexicon": 1,
|
||||||
|
"id": "ai.syui.rse.admin",
|
||||||
|
"defs": {
|
||||||
|
"main": {
|
||||||
|
"type": "record",
|
||||||
|
"key": "literal:self",
|
||||||
|
"description": "RSE admin configuration - abilities, characters, systems and collections",
|
||||||
|
"record": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["ability", "createdAt", "updatedAt"],
|
||||||
|
"properties": {
|
||||||
|
"ability": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Ability/attribute definitions",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "kind"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer", "description": "Ability ID" },
|
||||||
|
"name": { "type": "string", "description": "Ability name (ai, quark, neutron, atom, sun)" },
|
||||||
|
"kind": { "type": "string", "description": "Attribute type (consciousness, matter)" },
|
||||||
|
"level": { "type": "integer", "description": "Hierarchy level (0=fundamental)" },
|
||||||
|
"relation": { "type": "array", "items": { "type": "integer" }, "description": "Advantage IDs" },
|
||||||
|
"weakness": { "type": "array", "items": { "type": "integer" }, "description": "Weakness IDs" },
|
||||||
|
"multiplier": { "type": "integer", "description": "Damage multiplier percent (e.g., 150 = 1.5x)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"character": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Character definitions",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "ability", "mode"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer", "description": "Character ID" },
|
||||||
|
"name": { "type": "string", "description": "Character name" },
|
||||||
|
"ability": { "type": "integer", "description": "Ability ID reference" },
|
||||||
|
"mode": { "type": "integer", "description": "Character mode" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"system": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "System definitions",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "name", "domain"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer", "description": "System ID" },
|
||||||
|
"name": { "type": "string", "description": "System name" },
|
||||||
|
"domain": { "type": "string", "description": "System domain (ability, unique, account, planet, origin)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collection": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "ATProto collection definitions",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "nsid", "name"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer", "description": "Collection ID" },
|
||||||
|
"nsid": { "type": "string", "description": "Namespaced identifier (e.g., ai.syui.card)" },
|
||||||
|
"name": { "type": "string", "description": "Collection short name" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"createdAt": { "type": "string", "format": "datetime" },
|
||||||
|
"updatedAt": { "type": "string", "format": "datetime" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
lexicons/ai.syui.rse.user.json
Normal file
48
lexicons/ai.syui.rse.user.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"$type": "com.atproto.lexicon.schema",
|
||||||
|
"lexicon": 1,
|
||||||
|
"id": "ai.syui.rse.user",
|
||||||
|
"defs": {
|
||||||
|
"main": {
|
||||||
|
"type": "record",
|
||||||
|
"key": "literal:self",
|
||||||
|
"description": "User character and item collection",
|
||||||
|
"record": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["character", "item", "createdAt", "updatedAt"],
|
||||||
|
"properties": {
|
||||||
|
"character": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "cp", "mode", "unique", "shiny"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"cp": { "type": "integer" },
|
||||||
|
"mode": { "type": "integer" },
|
||||||
|
"unique": { "type": "boolean" },
|
||||||
|
"shiny": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "cp", "mode", "unique", "shiny"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "integer" },
|
||||||
|
"cp": { "type": "integer" },
|
||||||
|
"mode": { "type": "integer" },
|
||||||
|
"unique": { "type": "boolean" },
|
||||||
|
"shiny": { "type": "boolean" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"createdAt": { "type": "string", "format": "datetime" },
|
||||||
|
"updatedAt": { "type": "string", "format": "datetime" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"app.bsky.feed.generator",
|
"app.bsky.feed.generator",
|
||||||
"app.bsky.feed.post",
|
"app.bsky.feed.post",
|
||||||
"app.bsky.graph.follow",
|
"app.bsky.graph.follow",
|
||||||
"app.bsky.graph.verification"
|
"app.bsky.graph.verification",
|
||||||
|
"com.atproto.lexicon.schema"
|
||||||
],
|
],
|
||||||
"did": "did:plc:6qyecktefllvenje24fcxnie",
|
"did": "did:plc:6qyecktefllvenje24fcxnie",
|
||||||
"handle": "ai.syui.ai"
|
"handle": "ai.syui.ai"
|
||||||
|
|||||||
@@ -52,14 +52,9 @@ function toUtcDatetime(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum cards to migrate (ATProto record size limit ~256KB)
|
|
||||||
const MAX_MIGRATE_CARDS = 1200
|
|
||||||
|
|
||||||
// Perform migration
|
// Perform migration
|
||||||
export async function performMigration(user: OldApiUser, cards: OldApiCard[]): Promise<boolean> {
|
export async function performMigration(user: OldApiUser, cards: OldApiCard[]): Promise<boolean> {
|
||||||
// Limit cards to avoid exceeding ATProto record size limit
|
const checksum = generateChecksum(user, cards)
|
||||||
const limitedCards = cards.slice(0, MAX_MIGRATE_CARDS)
|
|
||||||
const checksum = generateChecksum(user, limitedCards)
|
|
||||||
|
|
||||||
// Convert user data (only required + used fields, matching lexicon types)
|
// Convert user data (only required + used fields, matching lexicon types)
|
||||||
// Note: ATProto doesn't support float, so planet is converted to integer
|
// Note: ATProto doesn't support float, so planet is converted to integer
|
||||||
@@ -74,20 +69,70 @@ export async function performMigration(user: OldApiUser, cards: OldApiCard[]): P
|
|||||||
updatedAt: toUtcDatetime(user.updated_at),
|
updatedAt: toUtcDatetime(user.updated_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert card data (only required + used fields)
|
// Merge cards by card number (sum cp, keep highest status)
|
||||||
const cardData = limitedCards.map(c => ({
|
const cardGroups = new Map<number, {
|
||||||
id: c.id,
|
card: number
|
||||||
|
totalCp: number
|
||||||
|
count: number
|
||||||
|
bestStatus: string
|
||||||
|
bestSkill: string
|
||||||
|
latestCreatedAt: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
for (const c of cards) {
|
||||||
|
const existing = cardGroups.get(c.card)
|
||||||
|
if (existing) {
|
||||||
|
existing.totalCp += c.cp
|
||||||
|
existing.count++
|
||||||
|
// Keep highest status (super > shiny > first > normal)
|
||||||
|
if (statusPriority(c.status) > statusPriority(existing.bestStatus)) {
|
||||||
|
existing.bestStatus = c.status || 'normal'
|
||||||
|
}
|
||||||
|
if (c.skill && c.skill !== 'normal') {
|
||||||
|
existing.bestSkill = c.skill
|
||||||
|
}
|
||||||
|
// Keep latest createdAt
|
||||||
|
if (c.created_at > existing.latestCreatedAt) {
|
||||||
|
existing.latestCreatedAt = c.created_at
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cardGroups.set(c.card, {
|
||||||
card: c.card,
|
card: c.card,
|
||||||
cp: c.cp,
|
totalCp: c.cp,
|
||||||
status: c.status || 'normal',
|
count: 1,
|
||||||
skill: c.skill || 'normal',
|
bestStatus: c.status || 'normal',
|
||||||
createdAt: toUtcDatetime(c.created_at),
|
bestSkill: c.skill || 'normal',
|
||||||
|
latestCreatedAt: c.created_at
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert merged data to card array
|
||||||
|
const cardData = Array.from(cardGroups.values())
|
||||||
|
.sort((a, b) => a.card - b.card)
|
||||||
|
.map(g => ({
|
||||||
|
id: g.card, // Use card number as id
|
||||||
|
card: g.card,
|
||||||
|
cp: g.totalCp,
|
||||||
|
status: g.bestStatus,
|
||||||
|
skill: g.bestSkill,
|
||||||
|
createdAt: toUtcDatetime(g.latestCreatedAt),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const result = await saveMigratedCardData(userData, cardData, checksum)
|
const result = await saveMigratedCardData(userData, cardData, checksum)
|
||||||
return result !== null
|
return result !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status priority for comparison (higher = better)
|
||||||
|
function statusPriority(status: string): number {
|
||||||
|
switch (status) {
|
||||||
|
case 'super': return 3
|
||||||
|
case 'shiny': return 2
|
||||||
|
case 'first': return 1
|
||||||
|
default: return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render migration icon for profile (shown when user has api.syui.ai account)
|
// Render migration icon for profile (shown when user has api.syui.ai account)
|
||||||
export function renderMigrationIcon(handle: string, hasOldApi: boolean, hasMigrated: boolean): string {
|
export function renderMigrationIcon(handle: string, hasOldApi: boolean, hasMigrated: boolean): string {
|
||||||
if (!hasOldApi) return ''
|
if (!hasOldApi) return ''
|
||||||
@@ -102,15 +147,6 @@ export function renderMigrationIcon(handle: string, hasOldApi: boolean, hasMigra
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
// Render migration page (simplified)
|
||||||
export function renderMigrationPage(
|
export function renderMigrationPage(
|
||||||
@@ -147,42 +183,39 @@ export function renderMigrationPage(
|
|||||||
buttonHtml = `<button id="migrate-btn" class="migrate-btn">Migrate</button>`
|
buttonHtml = `<button id="migrate-btn" class="migrate-btn">Migrate</button>`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Card grid (same style as /card page)
|
// Card grid - merge by card number (same as migration logic)
|
||||||
const cardGroups = new Map<number, { card: OldApiCard, count: number, maxCp: number, rare: number }>()
|
const cardGroups = new Map<number, { card: number, totalCp: number, rare: number }>()
|
||||||
for (const card of oldApiCards) {
|
for (const c of oldApiCards) {
|
||||||
const existing = cardGroups.get(card.card)
|
const existing = cardGroups.get(c.card)
|
||||||
const rare = statusToRare(card.status)
|
const rare = statusPriority(c.status)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.count++
|
existing.totalCp += c.cp
|
||||||
if (card.cp > existing.maxCp) existing.maxCp = card.cp
|
|
||||||
if (rare > existing.rare) existing.rare = rare
|
if (rare > existing.rare) existing.rare = rare
|
||||||
} else {
|
} else {
|
||||||
cardGroups.set(card.card, { card, count: 1, maxCp: card.cp, rare })
|
cardGroups.set(c.card, { card: c.card, totalCp: c.cp, rare })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedGroups = Array.from(cardGroups.values())
|
const sortedGroups = Array.from(cardGroups.values())
|
||||||
.sort((a, b) => a.card.card - b.card.card)
|
.sort((a, b) => a.card - b.card)
|
||||||
|
|
||||||
const cardsHtml = sortedGroups.map(({ card, count, maxCp, rare }) => {
|
const cardsHtml = sortedGroups.map(({ card, totalCp, rare }) => {
|
||||||
const rarityClass = rare === 3 ? 'unique' : rare === 2 ? 'shiny' : rare === 1 ? 'rare' : ''
|
const rarityClass = rare === 3 ? 'unique' : rare === 2 ? 'shiny' : rare === 1 ? 'rare' : ''
|
||||||
const effectsHtml = rarityClass ? `
|
const effectsHtml = rarityClass ? `
|
||||||
<div class="card-status pattern-${rarityClass}"></div>
|
<div class="card-status pattern-${rarityClass}"></div>
|
||||||
<div class="card-status color-${rarityClass}"></div>
|
<div class="card-status color-${rarityClass}"></div>
|
||||||
` : ''
|
` : ''
|
||||||
const countBadge = count > 1 ? `<span class="card-count">x${count}</span>` : ''
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card-item">
|
<div class="card-item">
|
||||||
<div class="card-wrapper">
|
<div class="card-wrapper">
|
||||||
<div class="card-reflection">
|
<div class="card-reflection">
|
||||||
<img src="/card/${card.card}.webp" alt="Card ${card.card}" loading="lazy" />
|
<img src="/card/${card}.webp" alt="Card ${card}" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
${effectsHtml}
|
${effectsHtml}
|
||||||
${countBadge}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-detail">
|
<div class="card-detail">
|
||||||
<span class="card-cp">${maxCp}</span>
|
<span class="card-cp">${totalCp}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
@@ -238,9 +271,9 @@ export function setupMigrationButton(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const migrateCount = Math.min(oldApiCards.length, MAX_MIGRATE_CARDS)
|
// Count unique card types
|
||||||
const limitMsg = oldApiCards.length > MAX_MIGRATE_CARDS ? ` (limited from ${oldApiCards.length})` : ''
|
const uniqueCards = new Set(oldApiCards.map(c => c.card)).size
|
||||||
if (!confirm(`Migrate ${migrateCount} cards${limitMsg} to ATProto?`)) {
|
if (!confirm(`Migrate ${oldApiCards.length} cards (merged to ${uniqueCards} types) to ATProto?`)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function setCurrentLang(lang: string): void {
|
|||||||
localStorage.setItem('preferred-lang', lang)
|
localStorage.setItem('preferred-lang', lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' | 'chat' = 'blog', isLocalUser: boolean = false): string {
|
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' | 'chat' | 'link' = 'blog', isLocalUser: boolean = false): string {
|
||||||
let tabs = `
|
let tabs = `
|
||||||
<a href="/" class="tab">/</a>
|
<a href="/" class="tab">/</a>
|
||||||
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
|
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
|
||||||
@@ -35,6 +35,7 @@ export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | '
|
|||||||
|
|
||||||
if (isLoggedIn()) {
|
if (isLoggedIn()) {
|
||||||
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
|
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
|
||||||
|
tabs += `<a href="/@${handle}/at/link" class="tab ${activeTab === 'link' ? 'active' : ''}">link</a>`
|
||||||
}
|
}
|
||||||
|
|
||||||
tabs += `
|
tabs += `
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons'
|
import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons'
|
||||||
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse, ChatMessage, CardCollection } from '../types'
|
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse, ChatMessage, CardCollection } from '../types'
|
||||||
|
|
||||||
|
// Fetch with timeout (default 10 seconds)
|
||||||
|
async function fetchWithTimeout(url: string, options: RequestInit = {}, timeout = 10000): Promise<Response> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { ...options, signal: controller.signal })
|
||||||
|
return res
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cache
|
// Cache
|
||||||
let configCache: AppConfig | null = null
|
let configCache: AppConfig | null = null
|
||||||
let networksCache: Networks | null = null
|
let networksCache: Networks | null = null
|
||||||
@@ -30,13 +43,13 @@ export async function resolveHandle(handle: string): Promise<string | null> {
|
|||||||
try {
|
try {
|
||||||
const host = network.bsky.replace('https://', '')
|
const host = network.bsky.replace('https://', '')
|
||||||
const url = `${xrpcUrl(host, comAtprotoIdentity.resolveHandle)}?handle=${handle}`
|
const url = `${xrpcUrl(host, comAtprotoIdentity.resolveHandle)}?handle=${handle}`
|
||||||
const res = await fetch(url)
|
const res = await fetchWithTimeout(url, {}, 5000)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return data.did
|
return data.did
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Try next network
|
// Try next network (timeout or error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -48,7 +61,7 @@ export async function getPds(did: string): Promise<string | null> {
|
|||||||
|
|
||||||
for (const network of Object.values(networks)) {
|
for (const network of Object.values(networks)) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${network.plc}/${did}`)
|
const res = await fetchWithTimeout(`${network.plc}/${did}`, {}, 5000)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const didDoc = await res.json()
|
const didDoc = await res.json()
|
||||||
const service = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')
|
const service = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer')
|
||||||
@@ -57,7 +70,7 @@ export async function getPds(did: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Try next network
|
// Try next network (timeout or error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -96,10 +109,10 @@ export async function getProfile(did: string, localOnly = false): Promise<Profil
|
|||||||
try {
|
try {
|
||||||
const host = pds.replace('https://', '')
|
const host = pds.replace('https://', '')
|
||||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=app.bsky.actor.profile&rkey=self`
|
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=app.bsky.actor.profile&rkey=self`
|
||||||
const res = await fetch(url)
|
const res = await fetchWithTimeout(url, {}, 8000)
|
||||||
if (res.ok) return res.json()
|
if (res.ok) return res.json()
|
||||||
} catch {
|
} catch {
|
||||||
// Failed
|
// Failed or timeout
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -227,13 +240,13 @@ export async function describeRepo(did: string): Promise<string[]> {
|
|||||||
try {
|
try {
|
||||||
const host = pds.replace('https://', '')
|
const host = pds.replace('https://', '')
|
||||||
const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}`
|
const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}`
|
||||||
const res = await fetch(url)
|
const res = await fetchWithTimeout(url, {}, 8000)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return data.collections || []
|
return data.collections || []
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Failed
|
// Failed or timeout
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -246,13 +259,13 @@ export async function listRecords(did: string, collection: string, limit = 50):
|
|||||||
try {
|
try {
|
||||||
const host = pds.replace('https://', '')
|
const host = pds.replace('https://', '')
|
||||||
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=${limit}`
|
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=${limit}`
|
||||||
const res = await fetch(url)
|
const res = await fetchWithTimeout(url, {}, 8000)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
return data.records || []
|
return data.records || []
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Failed
|
// Failed or timeout
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -265,10 +278,10 @@ export async function getRecord(did: string, collection: string, rkey: string):
|
|||||||
try {
|
try {
|
||||||
const host = pds.replace('https://', '')
|
const host = pds.replace('https://', '')
|
||||||
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=${rkey}`
|
const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=${rkey}`
|
||||||
const res = await fetch(url)
|
const res = await fetchWithTimeout(url, {}, 8000)
|
||||||
if (res.ok) return res.json()
|
if (res.ok) return res.json()
|
||||||
} catch {
|
} catch {
|
||||||
// Failed
|
// Failed or timeout
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,9 +167,10 @@ async function render(route: Route): Promise<void> {
|
|||||||
// Build page
|
// Build page
|
||||||
let html = renderHeader(handle, oauthEnabled)
|
let html = renderHeader(handle, oauthEnabled)
|
||||||
|
|
||||||
// Mode tabs (Blog/Browser/Post/Chat/PDS)
|
// Mode tabs (Blog/Browser/Post/Chat/Link/PDS)
|
||||||
const activeTab = route.type === 'postpage' ? 'post' :
|
const activeTab = route.type === 'postpage' ? 'post' :
|
||||||
(route.type === 'chat' || route.type === 'chat-thread' || route.type === 'chat-edit') ? 'chat' :
|
(route.type === 'chat' || route.type === 'chat-thread' || route.type === 'chat-edit') ? 'chat' :
|
||||||
|
route.type === 'link' ? 'link' :
|
||||||
(route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
|
(route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
|
||||||
html += renderModeTabs(handle, activeTab, localOnly)
|
html += renderModeTabs(handle, activeTab, localOnly)
|
||||||
|
|
||||||
@@ -441,9 +442,9 @@ async function render(route: Route): Promise<void> {
|
|||||||
<div class="error">Error: ${error}</div>
|
<div class="error">Error: ${error}</div>
|
||||||
${renderFooter(currentHandle, undefined)}
|
${renderFooter(currentHandle, undefined)}
|
||||||
`
|
`
|
||||||
hideLoading(app)
|
|
||||||
setupEventHandlers()
|
setupEventHandlers()
|
||||||
} finally {
|
} finally {
|
||||||
|
hideLoading(app)
|
||||||
isFirstRender = false
|
isFirstRender = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,6 +282,14 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tabs::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
@@ -293,6 +301,8 @@ body {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
|
|||||||
Reference in New Issue
Block a user