diff --git a/src/web/components/card.ts b/src/web/components/card.ts index 41357a8..bbc9aba 100644 --- a/src/web/components/card.ts +++ b/src/web/components/card.ts @@ -14,6 +14,33 @@ export interface CardCollection { updatedAt: string } +export interface CardAdminEntry { + id: number + character: number + name: { ja: string; en: string } + text: { ja: string; en: string } + cp: string + effect: string + key?: string | null +} + +export interface CardAdminData { + gacha: { pickup: number; rate: { rare: number; pickup: number } } + card: CardAdminEntry[] +} + +// Get current language +function getLang(): string { + return localStorage.getItem('preferredLang') || 'ja' +} + +// Get localized text +function getLocalizedText(obj: { ja: string; en: string } | undefined): string { + if (!obj) return '' + const lang = getLang() + return obj[lang as 'ja' | 'en'] || obj.ja || obj.en || '' +} + // Get rarity class name function getRarityClass(card: UserCard): string { if (card.unique) return 'unique' @@ -21,9 +48,13 @@ function getRarityClass(card: UserCard): string { return '' } - -// Render single card with optional count badge -export function renderCard(card: UserCard, baseUrl: string = '/card', count?: number): string { +// Render single card with optional count badge and admin info +export function renderCard( + card: UserCard, + baseUrl: string = '/card', + count?: number, + adminEntry?: CardAdminEntry +): string { const rarityClass = getRarityClass(card) const imageUrl = `${baseUrl}/${card.id}.webp` @@ -34,6 +65,21 @@ export function renderCard(card: UserCard, baseUrl: string = '/card', count?: nu const countBadge = count && count > 1 ? `x${count}` : '' + // Admin info (name, key, text) + const name = adminEntry ? getLocalizedText(adminEntry.name) : '' + const text = adminEntry ? getLocalizedText(adminEntry.text) : '' + const key = adminEntry?.key || '' + + const infoHtml = (name || text || key) ? ` +
+
+ ${name} + ${key ? `` : ''} +
+ ${text ? `
${text}
` : ''} +
+ ` : '' + return `
@@ -46,6 +92,7 @@ export function renderCard(card: UserCard, baseUrl: string = '/card', count?: nu
${card.cp}
+ ${infoHtml}
` } diff --git a/src/web/components/rse.ts b/src/web/components/rse.ts index 7842816..df08a79 100644 --- a/src/web/components/rse.ts +++ b/src/web/components/rse.ts @@ -1,4 +1,5 @@ // RSE display component for ai.syui.rse.user collection +import { renderCard, type UserCard, type CardAdminEntry, type CardAdminData } from './card' export interface RseItem { id: number @@ -20,8 +21,85 @@ function getRarityClass(item: RseItem): string { return '' } -// Render single item/character -function renderRseItem(item: RseItem, type: 'item' | 'character'): string { +// Get cards for a character (character 0 = cards 0-99, character 1 = cards 100-199, etc.) +function getCardsForCharacter( + characterId: number, + userCards: UserCard[], + adminData: CardAdminData | null +): { card: UserCard; adminEntry?: CardAdminEntry }[] { + const minId = characterId * 100 + const maxId = minId + 99 + + // Build admin lookup map + const adminMap = new Map() + if (adminData?.card) { + for (const entry of adminData.card) { + adminMap.set(entry.id, entry) + } + } + + // Filter and dedupe cards for this character + const cardGroups = new Map() + for (const card of userCards) { + if (card.id >= minId && card.id <= maxId) { + const existing = cardGroups.get(card.id) + if (!existing || card.cp > existing.cp || card.unique) { + cardGroups.set(card.id, card) + } + } + } + + // Sort by ID and add admin entries + return Array.from(cardGroups.values()) + .sort((a, b) => a.id - b.id) + .map(card => ({ + card, + adminEntry: adminMap.get(card.id) + })) +} + +// Render character section with its cards below +function renderCharacterSection( + item: RseItem, + userCards: UserCard[], + adminData: CardAdminData | null +): string { + const rarityClass = getRarityClass(item) + const effectsHtml = rarityClass ? ` +
+
+ ` : '' + + // Get cards for this character + const characterCards = getCardsForCharacter(item.id, userCards, adminData) + const cardsHtml = characterCards.map(({ card, adminEntry }) => + renderCard(card, '/card', undefined, adminEntry) + ).join('') + + return ` +
+
+
+
+
+ character ${item.id} +
+ ${effectsHtml} +
+
+ ${item.cp} +
+
+
+ ${characterCards.length > 0 ? ` +
${cardsHtml}
+ ` : ''} +
+ ` +} + +// Render single item +function renderRseItem(item: RseItem): string { const rarityClass = getRarityClass(item) const effectsHtml = rarityClass ? `
@@ -32,7 +110,7 @@ function renderRseItem(item: RseItem, type: 'item' | 'character'): string {
- ${type} ${item.id} + item ${item.id}
${effectsHtml}
@@ -46,7 +124,9 @@ function renderRseItem(item: RseItem, type: 'item' | 'character'): string { // Render RSE page export function renderRsePage( collection: RseCollection | null, - handle: string + handle: string, + userCards: UserCard[] = [], + adminData: CardAdminData | null = null ): string { const jsonUrl = `/@${handle}/at/collection/ai.syui.rse.user/self` @@ -70,19 +150,14 @@ export function renderRsePage( const totalItems = items.length const uniqueChars = characters.filter(c => c.unique).length - // Sort by unique > id - const sortedChars = [...characters].sort((a, b) => { - if (a.unique !== b.unique) return a.unique ? -1 : 1 - return a.id - b.id - }) + // Sort by id + const sortedChars = [...characters].sort((a, b) => a.id - b.id) + const sortedItems = [...items].sort((a, b) => a.id - b.id) - const sortedItems = [...items].sort((a, b) => { - if (a.unique !== b.unique) return a.unique ? -1 : 1 - return a.id - b.id - }) - - const charsHtml = sortedChars.map(c => renderRseItem(c, 'character')).join('') - const itemsHtml = sortedItems.map(i => renderRseItem(i, 'item')).join('') + const charsHtml = sortedChars.map(c => + renderCharacterSection(c, userCards, adminData) + ).join('') + const itemsHtml = sortedItems.map(i => renderRseItem(i)).join('') return `
@@ -107,7 +182,7 @@ export function renderRsePage(
${charsHtml ? `

Characters

-
${charsHtml}
+
${charsHtml}
` : ''} ${itemsHtml ? `

Items

diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts index b6e3752..755efa4 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -679,6 +679,55 @@ export interface LinkCollection { updatedAt?: string } +// Card admin data types +export interface CardAdminEntry { + id: number + character: number + name: { ja: string; en: string } + text: { ja: string; en: string } + cp: string + effect: string + key?: string | null +} + +export interface CardAdminData { + gacha: { pickup: number; rate: { rare: number; pickup: number } } + card: CardAdminEntry[] +} + +// Get card admin data (ai.syui.card.admin) +export async function getCardAdmin(did: string): Promise { + const collection = 'ai.syui.card.admin' + + // Try local first + try { + const res = await fetch(`/content/${did}/${collection}/self.json`) + if (res.ok && isJsonResponse(res)) { + const record = await res.json() + return record.value as CardAdminData + } + } 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 CardAdminData + } + } catch { + // Failed + } + return null +} + // Get user's links (ai.syui.at.link) export async function getLinks(did: string): Promise { const collection = 'ai.syui.at.link' diff --git a/src/web/main.ts b/src/web/main.ts index 459034f..0ee84a6 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -1,7 +1,7 @@ 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, getRse, getLinks } from './lib/api' +import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards, getCardAdmin, getRse, getLinks } from './lib/api' import { parseRoute, onRouteChange, navigate, type Route } from './lib/router' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost, updateChat, updateLinks } from './lib/auth' import { validateRecord } from './lib/lexicon' @@ -253,9 +253,16 @@ async function render(route: Route): Promise { html += `` } else if (route.type === 'rse') { - // RSE page - const rseData = await getRse(did) - html += `
${renderRsePage(rseData, handle)}
` + // RSE page with character cards + const cardCollection = config.cardCollection || 'ai.syui.card.user' + const adminDid = config.bot?.did || config.did || did + const [rseData, cards, adminData] = await Promise.all([ + getRse(did), + getCards(did, cardCollection), + getCardAdmin(adminDid) + ]) + const userCards = cards?.card || [] + html += `
${renderRsePage(rseData, handle, userCards, adminData)}
` html += `` } else if (route.type === 'link') { diff --git a/src/web/styles/card.css b/src/web/styles/card.css index 0cd61ed..67eec6b 100644 --- a/src/web/styles/card.css +++ b/src/web/styles/card.css @@ -522,3 +522,127 @@ color: var(--text-secondary, #aaa); } } + +/* RSE section title */ +.rse-section-title { + margin: 24px 0 12px; + font-size: 1.1em; + color: var(--text-primary, #333); +} + +/* RSE character sections */ +.rse-characters { + display: flex; + flex-direction: column; + gap: 32px; +} + +.rse-character-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.rse-character-main { + display: flex; + justify-content: center; +} + +.rse-character-main .card-wrapper { + width: 250px; + max-width: 250px; +} + +/* RSE card grid (smaller cards below character) */ +.rse-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 16px; + width: 100%; + max-width: 500px; + padding: 16px; + background: rgba(128, 128, 128, 0.08); + border-radius: 12px; +} + +.rse-card-grid .card-item { + max-width: 140px; +} + +.rse-card-grid .card-wrapper { + max-width: 140px; +} + +/* Card info (below card) */ +.card-info { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin-top: 8px; + text-align: center; +} + +.card-info-header { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + position: relative; +} + +.card-info-name { + font-size: 13px; + font-weight: 600; + color: var(--text-primary, #333); +} + +.card-info-header .card-key-btn { + position: absolute; + right: 0; +} + +.card-info-text { + font-size: 11px; + color: var(--text-secondary, #666); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-key-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + color: white; + padding: 3px 10px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + cursor: default; + box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Dark mode for RSE */ +@media (prefers-color-scheme: dark) { + .rse-section-title { + color: var(--text-primary, #eee); + } + + .rse-card-grid { + background: rgba(255, 255, 255, 0.05); + } + + .card-info-name { + color: var(--text-primary, #eee); + } + + .card-info-text { + color: var(--text-secondary, #aaa); + } +}