fix card info layout

This commit is contained in:
2026-01-28 17:19:49 +09:00
parent ab34468717
commit fc3f2da3c8
5 changed files with 326 additions and 24 deletions

View File

@@ -14,6 +14,33 @@ export interface CardCollection {
updatedAt: string 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 // Get rarity class name
function getRarityClass(card: UserCard): string { function getRarityClass(card: UserCard): string {
if (card.unique) return 'unique' if (card.unique) return 'unique'
@@ -21,9 +48,13 @@ function getRarityClass(card: UserCard): string {
return '' return ''
} }
// Render single card with optional count badge and admin info
// Render single card with optional count badge export function renderCard(
export function renderCard(card: UserCard, baseUrl: string = '/card', count?: number): string { card: UserCard,
baseUrl: string = '/card',
count?: number,
adminEntry?: CardAdminEntry
): string {
const rarityClass = getRarityClass(card) const rarityClass = getRarityClass(card)
const imageUrl = `${baseUrl}/${card.id}.webp` 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 ? `<span class="card-count">x${count}</span>` : '' const countBadge = count && count > 1 ? `<span class="card-count">x${count}</span>` : ''
// 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) ? `
<div class="card-info">
<div class="card-info-header">
<span class="card-info-name">${name}</span>
${key ? `<button class="card-key-btn">${key}</button>` : ''}
</div>
${text ? `<div class="card-info-text">${text}</div>` : ''}
</div>
` : ''
return ` return `
<div class="card-item"> <div class="card-item">
<div class="card-wrapper" data-card-id="${card.id}" data-cid="${card.cid}"> <div class="card-wrapper" data-card-id="${card.id}" data-cid="${card.cid}">
@@ -46,6 +92,7 @@ export function renderCard(card: UserCard, baseUrl: string = '/card', count?: nu
<div class="card-detail"> <div class="card-detail">
<span class="card-cp">${card.cp}</span> <span class="card-cp">${card.cp}</span>
</div> </div>
${infoHtml}
</div> </div>
` `
} }

View File

@@ -1,4 +1,5 @@
// RSE display component for ai.syui.rse.user collection // RSE display component for ai.syui.rse.user collection
import { renderCard, type UserCard, type CardAdminEntry, type CardAdminData } from './card'
export interface RseItem { export interface RseItem {
id: number id: number
@@ -20,8 +21,85 @@ function getRarityClass(item: RseItem): string {
return '' return ''
} }
// Render single item/character // Get cards for a character (character 0 = cards 0-99, character 1 = cards 100-199, etc.)
function renderRseItem(item: RseItem, type: 'item' | 'character'): string { 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<number, CardAdminEntry>()
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<number, UserCard>()
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 ? `
<div class="card-status pattern-${rarityClass}"></div>
<div class="card-status color-${rarityClass}"></div>
` : ''
// 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 `
<div class="rse-character-section">
<div class="rse-character-main">
<div class="card-item">
<div class="card-wrapper">
<div class="card-reflection">
<img src="/rse/character/${item.id}.webp" alt="character ${item.id}" loading="lazy" />
</div>
${effectsHtml}
</div>
<div class="card-detail">
<span class="card-cp">${item.cp}</span>
</div>
</div>
</div>
${characterCards.length > 0 ? `
<div class="rse-card-grid">${cardsHtml}</div>
` : ''}
</div>
`
}
// Render single item
function renderRseItem(item: RseItem): string {
const rarityClass = getRarityClass(item) const rarityClass = getRarityClass(item)
const effectsHtml = rarityClass ? ` const effectsHtml = rarityClass ? `
<div class="card-status pattern-${rarityClass}"></div> <div class="card-status pattern-${rarityClass}"></div>
@@ -32,7 +110,7 @@ function renderRseItem(item: RseItem, type: 'item' | 'character'): string {
<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="/rse/${type}/${item.id}.webp" alt="${type} ${item.id}" loading="lazy" /> <img src="/rse/item/${item.id}.webp" alt="item ${item.id}" loading="lazy" />
</div> </div>
${effectsHtml} ${effectsHtml}
</div> </div>
@@ -46,7 +124,9 @@ function renderRseItem(item: RseItem, type: 'item' | 'character'): string {
// Render RSE page // Render RSE page
export function renderRsePage( export function renderRsePage(
collection: RseCollection | null, collection: RseCollection | null,
handle: string handle: string,
userCards: UserCard[] = [],
adminData: CardAdminData | null = null
): string { ): string {
const jsonUrl = `/@${handle}/at/collection/ai.syui.rse.user/self` const jsonUrl = `/@${handle}/at/collection/ai.syui.rse.user/self`
@@ -70,19 +150,14 @@ export function renderRsePage(
const totalItems = items.length const totalItems = items.length
const uniqueChars = characters.filter(c => c.unique).length const uniqueChars = characters.filter(c => c.unique).length
// Sort by unique > id // Sort by id
const sortedChars = [...characters].sort((a, b) => { const sortedChars = [...characters].sort((a, b) => a.id - b.id)
if (a.unique !== b.unique) return a.unique ? -1 : 1 const sortedItems = [...items].sort((a, b) => a.id - b.id)
return a.id - b.id
})
const sortedItems = [...items].sort((a, b) => { const charsHtml = sortedChars.map(c =>
if (a.unique !== b.unique) return a.unique ? -1 : 1 renderCharacterSection(c, userCards, adminData)
return a.id - b.id ).join('')
}) const itemsHtml = sortedItems.map(i => renderRseItem(i)).join('')
const charsHtml = sortedChars.map(c => renderRseItem(c, 'character')).join('')
const itemsHtml = sortedItems.map(i => renderRseItem(i, 'item')).join('')
return ` return `
<div class="card-page"> <div class="card-page">
@@ -107,7 +182,7 @@ export function renderRsePage(
</div> </div>
${charsHtml ? ` ${charsHtml ? `
<h3 class="rse-section-title">Characters</h3> <h3 class="rse-section-title">Characters</h3>
<div class="card-grid">${charsHtml}</div> <div class="rse-characters">${charsHtml}</div>
` : ''} ` : ''}
${itemsHtml ? ` ${itemsHtml ? `
<h3 class="rse-section-title">Items</h3> <h3 class="rse-section-title">Items</h3>

View File

@@ -679,6 +679,55 @@ export interface LinkCollection {
updatedAt?: string 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<CardAdminData | null> {
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) // Get user's links (ai.syui.at.link)
export async function getLinks(did: string): Promise<LinkCollection | null> { export async function getLinks(did: string): Promise<LinkCollection | null> {
const collection = 'ai.syui.at.link' const collection = 'ai.syui.at.link'

View File

@@ -1,7 +1,7 @@
import './styles/main.css' import './styles/main.css'
import './styles/card.css' import './styles/card.css'
import './styles/card-migrate.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 { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost, updateChat, updateLinks } from './lib/auth' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost, updateChat, updateLinks } from './lib/auth'
import { validateRecord } from './lib/lexicon' import { validateRecord } from './lib/lexicon'
@@ -253,9 +253,16 @@ async function render(route: Route): Promise<void> {
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>` html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'rse') { } else if (route.type === 'rse') {
// RSE page // RSE page with character cards
const rseData = await getRse(did) const cardCollection = config.cardCollection || 'ai.syui.card.user'
html += `<div id="content">${renderRsePage(rseData, handle)}</div>` 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 += `<div id="content">${renderRsePage(rseData, handle, userCards, adminData)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>` html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'link') { } else if (route.type === 'link') {

View File

@@ -522,3 +522,127 @@
color: var(--text-secondary, #aaa); 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);
}
}