test card

This commit is contained in:
2026-01-21 00:48:00 +09:00
parent 42d4c21b9f
commit 050102dd44
8 changed files with 696 additions and 18 deletions

1
public/card Symbolic link
View File

@@ -0,0 +1 @@
/Users/syui/ai/card/assets

167
src/web/components/card.ts Normal file
View File

@@ -0,0 +1,167 @@
// Card display component for ai.syui.card.user collection
export interface UserCard {
id: number
cp: number
rare: number
cid: string
}
export interface CardCollection {
card: UserCard[]
createdAt: string
updatedAt: string
}
// Get rarity class name
function getRarityClass(rare: number): string {
switch (rare) {
case 1: return 'rare'
case 2: return 'shiny'
case 3: return 'unique'
default: return ''
}
}
// Render single card with optional count badge
export function renderCard(card: UserCard, baseUrl: string = '/card', count?: number): string {
const rarityClass = getRarityClass(card.rare)
const imageUrl = `${baseUrl}/${card.id}.webp`
const effectsHtml = rarityClass ? `
<div class="card-status pattern-${rarityClass}"></div>
<div class="card-status color-${rarityClass}"></div>
` : ''
const countBadge = count && count > 1 ? `<span class="card-count">x${count}</span>` : ''
return `
<div class="card-item">
<div class="card-wrapper" data-card-id="${card.id}" data-cid="${card.cid}">
<div class="card-reflection">
<img src="${imageUrl}" alt="Card ${card.id}" loading="lazy" />
</div>
${effectsHtml}
${countBadge}
</div>
<div class="card-detail">
<span class="card-cp">${card.cp}</span>
</div>
</div>
`
}
// Render card grid
export function renderCardGrid(cards: UserCard[], baseUrl?: string): string {
if (!cards || cards.length === 0) {
return '<div class="no-cards">No cards found</div>'
}
const cardsHtml = cards.map(card => renderCard(card, baseUrl)).join('')
return `<div class="card-grid">${cardsHtml}</div>`
}
// Render card page with stats
export function renderCardPage(
collection: CardCollection | null,
handle: string,
cardCollection: string = 'ai.syui.card.user'
): string {
const jsonUrl = `/@${handle}/at/collection/${cardCollection}/self`
if (!collection || !collection.card || collection.card.length === 0) {
return `
<div class="card-page">
<div class="card-header">
<h2>Cards</h2>
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
<p class="no-cards">No cards found for @${handle}</p>
</div>
`
}
const cards = collection.card
const totalCards = cards.length
const totalCp = cards.reduce((sum, c) => sum + c.cp, 0)
// Count by rarity
const rarityCount = {
normal: cards.filter(c => c.rare === 0).length,
rare: cards.filter(c => c.rare === 1).length,
shiny: cards.filter(c => c.rare === 2).length,
unique: cards.filter(c => c.rare === 3).length,
}
// Group cards by id and count
const cardGroups = new Map<number, { card: UserCard, count: number }>()
for (const card of cards) {
const existing = cardGroups.get(card.id)
if (existing) {
existing.count++
// Keep the highest CP/rarity version
if (card.cp > existing.card.cp || card.rare > existing.card.rare) {
existing.card = card
}
} else {
cardGroups.set(card.id, { card, count: 1 })
}
}
// Sort by rarity (desc), then by id
const sortedGroups = Array.from(cardGroups.values())
.sort((a, b) => {
if (b.card.rare !== a.card.rare) return b.card.rare - a.card.rare
return a.card.id - b.card.id
})
const cardsHtml = sortedGroups.map(({ card, count }) => {
return renderCard(card, '/card', count)
}).join('')
return `
<div class="card-page">
<div class="card-header">
<div class="card-stats">
<div class="stat">
<span class="stat-value">${totalCards}</span>
<span class="stat-label">Total</span>
</div>
<div class="stat">
<span class="stat-value">${totalCp}</span>
<span class="stat-label">CP</span>
</div>
<div class="stat rare-unique">
<span class="stat-value">${rarityCount.unique}</span>
<span class="stat-label">Unique</span>
</div>
<div class="stat rare-shiny">
<span class="stat-value">${rarityCount.shiny}</span>
<span class="stat-label">Shiny</span>
</div>
<div class="stat rare-rare">
<span class="stat-value">${rarityCount.rare}</span>
<span class="stat-label">Rare</span>
</div>
</div>
</div>
<div class="card-actions">
<a href="${jsonUrl}" class="json-btn">json</a>
</div>
<div class="card-grid">${cardsHtml}</div>
</div>
`
}
// Render service icons (shown in profile for logged-in users)
export function renderServiceIcons(_handle: string, services: { name: string, icon: string, url: string }[]): string {
const iconsHtml = services.map(s => `
<a href="${s.url}" class="service-icon-link" title="${s.name}">
<img src="${s.icon}" alt="${s.name}" class="service-icon" />
</a>
`).join('')
return `<div class="service-icons">${iconsHtml}</div>`
}

View File

@@ -1,12 +1,38 @@
import type { Profile } from '../types' import type { Profile } from '../types'
import { getAvatarUrl, getAvatarUrlRemote } from '../lib/api' import { getAvatarUrl, getAvatarUrlRemote } from '../lib/api'
// Service definitions for profile icons
export interface ServiceLink {
name: string
icon: string
url: string
collection: string
}
// Get available services based on user's collections
export function getServiceLinks(handle: string, collections: string[]): ServiceLink[] {
const services: ServiceLink[] = []
if (collections.includes('ai.syui.card.user')) {
services.push({
name: 'Card',
icon: '/service/ai.syui.card.png',
url: `/@${handle}/at/card`,
collection: 'ai.syui.card.user'
})
}
return services
}
export async function renderProfile( export async function renderProfile(
did: string, did: string,
profile: Profile, profile: Profile,
handle: string, handle: string,
webUrl?: string, webUrl?: string,
localOnly = false localOnly = false,
isLoggedIn = false,
collections: string[] = []
): Promise<string> { ): Promise<string> {
// Local mode: sync, no API call. Remote mode: async with API call // Local mode: sync, no API call. Remote mode: async with API call
const avatarUrl = localOnly const avatarUrl = localOnly
@@ -26,6 +52,20 @@ export async function renderProfile(
? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">` ? `<img src="${avatarUrl}" alt="${escapeHtml(displayName)}" class="profile-avatar">`
: `<div class="profile-avatar-placeholder"></div>` : `<div class="profile-avatar-placeholder"></div>`
// Service icons (only for logged-in users with matching collections)
let serviceIconsHtml = ''
if (isLoggedIn && collections.length > 0) {
const services = getServiceLinks(handle, collections)
if (services.length > 0) {
const iconsHtml = services.map(s => `
<a href="${s.url}" class="service-icon-link" title="${s.name}">
<img src="${s.icon}" alt="${s.name}" class="service-icon" />
</a>
`).join('')
serviceIconsHtml = `<div class="service-icons">${iconsHtml}</div>`
}
}
return ` return `
<div class="profile"> <div class="profile">
${avatarHtml} ${avatarHtml}
@@ -34,6 +74,7 @@ export async function renderProfile(
<p class="profile-handle">${handleHtml}</p> <p class="profile-handle">${handleHtml}</p>
${description ? `<p class="profile-desc">${escapeHtml(description)}</p>` : ''} ${description ? `<p class="profile-desc">${escapeHtml(description)}</p>` : ''}
</div> </div>
${serviceIconsHtml}
</div> </div>
` `
} }

View File

@@ -1,5 +1,5 @@
import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons' import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons'
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse, ChatMessage } from '../types' import type { AppConfig, Networks, Profile, Post, ListRecordsResponse, ChatMessage, CardCollection } from '../types'
// Cache // Cache
let configCache: AppConfig | null = null let configCache: AppConfig | null = null
@@ -428,3 +428,37 @@ export async function getChatMessages(
new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime() new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime()
) )
} }
// Get user's card collection (ai.syui.card.user)
export async function getCards(
did: string,
collection: string = 'ai.syui.card.user'
): Promise<CardCollection | null> {
// 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 CardCollection
}
} 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 CardCollection
}
} catch {
// Failed
}
return null
}

View File

@@ -1,5 +1,5 @@
export interface Route { export interface Route {
type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread' | 'card'
handle?: string handle?: string
rkey?: string rkey?: string
service?: string service?: string
@@ -51,6 +51,12 @@ export function parseRoute(): Route {
return { type: 'postpage', handle: postPageMatch[1] } return { type: 'postpage', handle: postPageMatch[1] }
} }
// Card page: /@handle/at/card
const cardMatch = path.match(/^\/@([^/]+)\/at\/card\/?$/)
if (cardMatch) {
return { type: 'card', handle: cardMatch[1] }
}
// Chat thread: /@handle/at/chat/{rkey} // Chat thread: /@handle/at/chat/{rkey}
const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/) const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/)
if (chatThreadMatch) { if (chatThreadMatch) {
@@ -91,6 +97,8 @@ export function navigate(route: Route): void {
path = `/@${route.handle}/at/collection/${route.collection}` path = `/@${route.handle}/at/collection/${route.collection}`
} else if (route.type === 'record' && route.handle && route.collection && route.rkey) { } else if (route.type === 'record' && route.handle && route.collection && route.rkey) {
path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}` 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 === 'chat' && route.handle) { } else if (route.type === 'chat' && route.handle) {
path = `/@${route.handle}/at/chat` path = `/@${route.handle}/at/chat`
} else if (route.type === 'chat-thread' && route.handle && route.rkey) { } else if (route.type === 'chat-thread' && route.handle && route.rkey) {

View File

@@ -1,5 +1,6 @@
import './styles/main.css' import './styles/main.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages } from './lib/api' import './styles/card.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages, getCards } 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 } from './lib/auth' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
import { validateRecord } from './lib/lexicon' import { validateRecord } from './lib/lexicon'
@@ -11,6 +12,7 @@ import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCol
import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs' import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs'
import { renderFooter } from './components/footer' import { renderFooter } from './components/footer'
import { renderChatListPage, renderChatThreadPage } from './components/chat' import { renderChatListPage, renderChatThreadPage } from './components/chat'
import { renderCardPage } from './components/card'
import { showLoading, hideLoading } from './components/loading' import { showLoading, hideLoading } from './components/loading'
const app = document.getElementById('app')! const app = document.getElementById('app')!
@@ -133,9 +135,12 @@ async function render(route: Route): Promise<void> {
return return
} }
// Load profile (local only for admin, remote for others) // Load profile and collections (local only for admin, remote for others)
const profile = await getProfile(did, localOnly) const [profile, webUrl, collections] = await Promise.all([
const webUrl = await getWebUrl(handle) getProfile(did, localOnly),
getWebUrl(handle),
describeRepo(did)
])
// Load posts (local only for admin, remote for others) // Load posts (local only for admin, remote for others)
const posts = await getPosts(did, config.collection, localOnly) const posts = await getPosts(did, config.collection, localOnly)
@@ -164,15 +169,15 @@ async function render(route: Route): Promise<void> {
(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)
// Profile section
if (profile) {
html += await renderProfile(did, profile, handle, webUrl, localOnly)
}
// Check if logged-in user owns this content // Check if logged-in user owns this content
const loggedInDid = getLoggedInDid() const loggedInDid = getLoggedInDid()
const isOwner = isLoggedIn() && loggedInDid === did const isOwner = isLoggedIn() && loggedInDid === did
// Profile section
if (profile) {
html += await renderProfile(did, profile, handle, webUrl, localOnly, isOwner, collections)
}
// Content section based on route type // Content section based on route type
let currentRecord: { uri: string; cid: string; value: unknown } | null = null let currentRecord: { uri: string; cid: string; value: unknown } | null = null
@@ -195,16 +200,14 @@ async function render(route: Route): Promise<void> {
html += `<nav class="back-nav"><a href="/@${handle}/at/service/${encodeURIComponent(service)}">${service}</a></nav>` html += `<nav class="back-nav"><a href="/@${handle}/at/service/${encodeURIComponent(service)}">${service}</a></nav>`
} else if (route.type === 'service' && route.service) { } else if (route.type === 'service' && route.service) {
// AT-Browser: Service collections list // AT-Browser: Service collections list (use pre-loaded collections)
const collections = await describeRepo(did)
const filtered = filterCollectionsByService(collections, route.service) const filtered = filterCollectionsByService(collections, route.service)
html += `<div id="content">${renderCollectionList(filtered, handle, route.service)}</div>` html += `<div id="content">${renderCollectionList(filtered, handle, route.service)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}/at">at</a></nav>` html += `<nav class="back-nav"><a href="/@${handle}/at">at</a></nav>`
} else if (route.type === 'atbrowser') { } else if (route.type === 'atbrowser') {
// AT-Browser: Main view with server info + service list // AT-Browser: Main view with server info + service list (use pre-loaded collections)
const pds = await getPds(did) const pds = await getPds(did)
const collections = await describeRepo(did)
html += `<div id="browser">` html += `<div id="browser">`
html += renderServerInfo(did, pds) html += renderServerInfo(did, pds)
@@ -228,6 +231,13 @@ async function render(route: Route): Promise<void> {
html += `<div id="post-form">${renderPostForm(config.collection)}</div>` html += `<div id="post-form">${renderPostForm(config.collection)}</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 === 'card') {
// Card collection page
const cardCollection = config.cardCollection || 'ai.syui.card.user'
const cards = await getCards(did, cardCollection)
html += `<div id="content">${renderCardPage(cards, handle, cardCollection)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'chat') { } else if (route.type === 'chat') {
// Chat list page - show threads started by this user // Chat list page - show threads started by this user
if (!config.bot) { if (!config.bot) {
@@ -299,8 +309,7 @@ async function render(route: Route): Promise<void> {
} }
} else { } else {
// User page: compact collection buttons + posts // User page: compact collection buttons + posts (use pre-loaded collections)
const collections = await describeRepo(did)
html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>` html += `<div id="browser">${renderCollectionButtons(collections, handle)}</div>`
// Language selector above content // Language selector above content

403
src/web/styles/card.css Normal file
View File

@@ -0,0 +1,403 @@
/* Card Display Effects - Based on ~/ai/card web-card-effects.ts */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
padding: 16px 0;
}
.card-wrapper {
display: grid;
place-items: center;
position: relative;
aspect-ratio: 5/7;
width: 100%;
max-width: 200px;
margin: 0 auto;
overflow: hidden;
cursor: pointer;
transition: transform 0.3s ease;
}
.card-wrapper:hover {
transform: scale(1.05);
}
.card-reflection {
display: block;
position: relative;
overflow: hidden;
border-radius: 7px;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
width: 100%;
height: 100%;
}
.card-reflection img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-reflection::after {
content: "";
height: 100%;
width: 30px;
position: absolute;
top: -180px;
left: 0;
background-color: #ffffffa8;
opacity: 0;
transform: rotate(45deg);
animation: reflection 4s ease-in-out infinite;
pointer-events: none;
}
@keyframes reflection {
0% { transform: scale(0) rotate(45deg); opacity: 0; }
80% { transform: scale(0) rotate(45deg); opacity: 0.5; }
81% { transform: scale(2) rotate(45deg); opacity: 1; }
100% { transform: scale(3) rotate(45deg); opacity: 0; }
}
.card-status {
aspect-ratio: 5/7;
border-radius: 7px;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
/* rare=0: Normal - no effect */
/* rare=1: Rare (Cyan/Turquoise - like "first") */
.pattern-rare {
background: repeating-radial-gradient(circle at -150% -25%,
rgba(0, 255, 255, 0.3),
transparent 3px,
rgba(64, 224, 208, 0.2) 6px);
background-position: 50% 50%;
background-size: 120% 120%;
mix-blend-mode: screen;
opacity: 0.7;
animation: shimmer 3s ease-in-out infinite;
}
.color-rare {
background: linear-gradient(115deg,
transparent 0%,
rgba(0, 255, 255, 0.4) 25%,
transparent 50%,
rgba(64, 224, 208, 0.3) 75%,
transparent 100%);
background-position: 50% 50%;
background-size: 200% 200%;
mix-blend-mode: soft-light;
opacity: 0.6;
animation: gradient-shift 4s ease-in-out infinite;
}
/* rare=2: Shiny (Yellow/Rainbow - like "yui") */
.pattern-shiny {
background: repeating-radial-gradient(circle at -150% -25%, rgba(255, 255, 0, 0.3), transparent 3px, rgba(255, 255, 0, 0.2) 6px);
background-position: 50% 50%;
background-size: 120% 120%;
mix-blend-mode: screen;
opacity: 0.7;
animation: shimmer 3s ease-in-out infinite;
}
.color-shiny {
background: linear-gradient(45deg,
#ff0000, #ff7f00, #ffff00, #00ff00,
#0000ff, #4b0082, #9400d3, #ff0000);
background-size: 400% 400%;
animation: rainbow-flow 3s ease-in-out infinite;
mix-blend-mode: color-dodge;
opacity: 0.3;
}
/* rare=3: Unique (Gold/Special - like "seven") */
.pattern-unique {
background: repeating-radial-gradient(circle at center, #fff700, #313131 3px, #000700 3px);
background-position: 50% 50%;
background-size: 120% 120%;
mix-blend-mode: screen;
opacity: 0.6;
animation: shimmer 3s ease-in-out infinite;
}
.color-unique {
background: linear-gradient(115deg, transparent 20%, #fff700 30%, transparent 48% 52%, #fff700 70%, transparent);
background-position: 50% 50%;
background-size: 200% 200%;
mix-blend-mode: soft-light;
opacity: 0.5;
animation: gradient-shift 4s ease-in-out infinite;
}
@keyframes shimmer {
0%, 100% { opacity: 0.4; background-size: 120% 120%; }
50% { opacity: 0.6; background-size: 140% 140%; }
}
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes rainbow-flow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
/* Enhanced holographic sweep effect */
.card-status.pattern-rare::after,
.card-status.pattern-shiny::after,
.card-status.pattern-unique::after {
content: "";
position: absolute;
top: -180px;
left: 0;
width: 30px;
height: 100%;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.8), transparent);
opacity: 0;
transform: rotate(45deg);
animation: holographic-sweep 6s ease-in-out infinite;
}
@keyframes holographic-sweep {
0% { transform: scale(0) rotate(45deg) translateX(-50px); opacity: 0; }
10% { transform: scale(0) rotate(45deg) translateX(-50px); opacity: 0; }
15% { transform: scale(1) rotate(45deg) translateX(-25px); opacity: 0.6; }
20% { transform: scale(1.5) rotate(45deg) translateX(0px); opacity: 1; }
25% { transform: scale(2) rotate(45deg) translateX(25px); opacity: 0.8; }
30% { transform: scale(2.5) rotate(45deg) translateX(50px); opacity: 0; }
100% { transform: scale(0) rotate(45deg) translateX(75px); opacity: 0; }
}
/* Floating particles effect */
.card-wrapper::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
radial-gradient(2px 2px at 20px 30px, rgba(255, 255, 255, 0.8), transparent),
radial-gradient(2px 2px at 40px 70px, rgba(255, 255, 255, 0.6), transparent),
radial-gradient(1px 1px at 90px 40px, rgba(255, 255, 255, 0.4), transparent),
radial-gradient(1px 1px at 130px 80px, rgba(255, 255, 255, 0.7), transparent);
background-repeat: repeat;
background-size: 200px 200px;
animation: float-particles 8s linear infinite;
opacity: 0;
pointer-events: none;
z-index: 15;
}
.card-wrapper:hover::before {
opacity: 1;
}
@keyframes float-particles {
0% { transform: translateY(0px) translateX(0px); }
33% { transform: translateY(-10px) translateX(5px); }
66% { transform: translateY(-5px) translateX(-5px); }
100% { transform: translateY(0px) translateX(0px); }
}
/* Hover acceleration */
.card-wrapper:hover .pattern-rare,
.card-wrapper:hover .pattern-shiny,
.card-wrapper:hover .pattern-unique {
animation-duration: 1.5s;
opacity: 0.7;
}
.card-wrapper:hover .color-rare,
.card-wrapper:hover .color-shiny,
.card-wrapper:hover .color-unique {
animation-duration: 2s;
opacity: 0.8;
}
/* Service icons in profile (right side) */
.service-icons {
display: flex;
flex-direction: column;
gap: 8px;
margin-left: auto;
align-self: flex-start;
}
.service-icon {
width: 36px;
height: 36px;
border-radius: 8px;
opacity: 0.8;
transition: opacity 0.2s, transform 0.2s;
}
.service-icon:hover {
opacity: 1;
transform: scale(1.1);
}
/* Card page layout */
.card-page {
padding: 16px 0;
}
.card-header {
margin-bottom: 8px;
}
.card-header h2 {
margin: 0;
}
.card-actions {
margin-bottom: 16px;
}
.card-stats {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding: 12px 16px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
flex: 1;
}
.card-stats .stat {
display: flex;
flex-direction: column;
align-items: center;
min-width: 50px;
padding: 4px 8px;
border-radius: 4px;
}
.card-stats .stat-value {
font-size: 1.2em;
font-weight: bold;
color: var(--text-primary, #333);
}
.card-stats .stat-label {
font-size: 0.75em;
color: var(--text-secondary, #666);
}
/* Rarity colored stats */
.card-stats .rare-unique {
background: rgba(255, 247, 0, 0.2);
}
.card-stats .rare-unique .stat-value {
color: #b8860b;
}
.card-stats .rare-shiny {
background: linear-gradient(135deg, rgba(255, 255, 0, 0.15), rgba(255, 127, 0, 0.15));
}
.card-stats .rare-shiny .stat-value {
color: #ff8c00;
}
.card-stats .rare-rare {
background: rgba(0, 255, 255, 0.15);
}
.card-stats .rare-rare .stat-value {
color: #20b2aa;
}
/* Card item with detail below */
.card-item {
display: flex;
flex-direction: column;
align-items: center;
}
.card-count {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 13px;
font-weight: bold;
z-index: 50;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.no-cards {
text-align: center;
padding: 32px;
color: var(--text-secondary, #666);
}
/* Card detail below card */
.card-detail {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 8px;
font-size: 14px;
color: var(--text-primary, #333);
}
.card-detail .card-cp {
font-weight: bold;
}
.card-detail .card-rarity {
font-size: 16px;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.card-stats {
background: var(--bg-secondary, #2a2a2a);
}
.card-stats .stat-value {
color: var(--text-primary, #eee);
}
.card-stats .stat-label {
color: var(--text-secondary, #aaa);
}
.card-stats .rare-unique .stat-value {
color: #ffd700;
}
.card-stats .rare-shiny .stat-value {
color: #ffa500;
}
.card-stats .rare-rare .stat-value {
color: #40e0d0;
}
.card-detail {
color: var(--text-primary, #eee);
}
.card-detail .card-status-text {
color: var(--text-secondary, #aaa);
}
}

View File

@@ -11,6 +11,7 @@ export interface AppConfig {
bot?: BotConfig bot?: BotConfig
collection: string collection: string
chatCollection?: string chatCollection?: string
cardCollection?: string
network: string network: string
color: string color: string
siteUrl: string siteUrl: string
@@ -92,3 +93,17 @@ export interface ChatMessage {
} }
} }
} }
// Card types
export interface UserCard {
id: number
cp: number
rare: number
cid: string
}
export interface CardCollection {
card: UserCard[]
createdAt: string
updatedAt: string
}