From 050102dd441172467f1afb614e9c86a499a64f96 Mon Sep 17 00:00:00 2001 From: syui Date: Wed, 21 Jan 2026 00:48:00 +0900 Subject: [PATCH] test card --- public/card | 1 + src/web/components/card.ts | 167 ++++++++++++++ src/web/components/profile.ts | 43 +++- src/web/lib/api.ts | 36 ++- src/web/lib/router.ts | 10 +- src/web/main.ts | 39 ++-- src/web/styles/card.css | 403 ++++++++++++++++++++++++++++++++++ src/web/types.ts | 15 ++ 8 files changed, 696 insertions(+), 18 deletions(-) create mode 120000 public/card create mode 100644 src/web/components/card.ts create mode 100644 src/web/styles/card.css diff --git a/public/card b/public/card new file mode 120000 index 0000000..4ee7751 --- /dev/null +++ b/public/card @@ -0,0 +1 @@ +/Users/syui/ai/card/assets \ No newline at end of file diff --git a/src/web/components/card.ts b/src/web/components/card.ts new file mode 100644 index 0000000..a56a1e2 --- /dev/null +++ b/src/web/components/card.ts @@ -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 ? ` +
+
+ ` : '' + + const countBadge = count && count > 1 ? `x${count}` : '' + + return ` +
+
+
+ Card ${card.id} +
+ ${effectsHtml} + ${countBadge} +
+
+ ${card.cp} +
+
+ ` +} + +// Render card grid +export function renderCardGrid(cards: UserCard[], baseUrl?: string): string { + if (!cards || cards.length === 0) { + return '
No cards found
' + } + + const cardsHtml = cards.map(card => renderCard(card, baseUrl)).join('') + + return `
${cardsHtml}
` +} + +// 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 ` +
+
+

Cards

+ json +
+

No cards found for @${handle}

+
+ ` + } + + 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() + 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 ` +
+
+
+
+ ${totalCards} + Total +
+
+ ${totalCp} + CP +
+
+ ${rarityCount.unique} + Unique +
+
+ ${rarityCount.shiny} + Shiny +
+
+ ${rarityCount.rare} + Rare +
+
+
+
+ json +
+
${cardsHtml}
+
+ ` +} + +// 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 => ` + + ${s.name} + + `).join('') + + return `
${iconsHtml}
` +} diff --git a/src/web/components/profile.ts b/src/web/components/profile.ts index 9cbdfcb..1d182af 100644 --- a/src/web/components/profile.ts +++ b/src/web/components/profile.ts @@ -1,12 +1,38 @@ import type { Profile } from '../types' 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( did: string, profile: Profile, handle: string, webUrl?: string, - localOnly = false + localOnly = false, + isLoggedIn = false, + collections: string[] = [] ): Promise { // Local mode: sync, no API call. Remote mode: async with API call const avatarUrl = localOnly @@ -26,6 +52,20 @@ export async function renderProfile( ? `${escapeHtml(displayName)}` : `
` + // 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 => ` + + ${s.name} + + `).join('') + serviceIconsHtml = `
${iconsHtml}
` + } + } + return `
${avatarHtml} @@ -34,6 +74,7 @@ export async function renderProfile(

${handleHtml}

${description ? `

${escapeHtml(description)}

` : ''}
+ ${serviceIconsHtml} ` } diff --git a/src/web/lib/api.ts b/src/web/lib/api.ts index 16a2f51..d0131f9 100644 --- a/src/web/lib/api.ts +++ b/src/web/lib/api.ts @@ -1,5 +1,5 @@ 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 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() ) } + +// Get user's card collection (ai.syui.card.user) +export async function getCards( + did: string, + collection: string = 'ai.syui.card.user' +): Promise { + // 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 +} diff --git a/src/web/lib/router.ts b/src/web/lib/router.ts index 1a158ad..b385c6a 100644 --- a/src/web/lib/router.ts +++ b/src/web/lib/router.ts @@ -1,5 +1,5 @@ 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 rkey?: string service?: string @@ -51,6 +51,12 @@ export function parseRoute(): Route { 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} const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/) if (chatThreadMatch) { @@ -91,6 +97,8 @@ export function navigate(route: Route): void { path = `/@${route.handle}/at/collection/${route.collection}` } else if (route.type === 'record' && route.handle && 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) { path = `/@${route.handle}/at/chat` } else if (route.type === 'chat-thread' && route.handle && route.rkey) { diff --git a/src/web/main.ts b/src/web/main.ts index 5fffdb9..1707f72 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -1,5 +1,6 @@ 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 { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth' import { validateRecord } from './lib/lexicon' @@ -11,6 +12,7 @@ import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCol import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs' import { renderFooter } from './components/footer' import { renderChatListPage, renderChatThreadPage } from './components/chat' +import { renderCardPage } from './components/card' import { showLoading, hideLoading } from './components/loading' const app = document.getElementById('app')! @@ -133,9 +135,12 @@ async function render(route: Route): Promise { return } - // Load profile (local only for admin, remote for others) - const profile = await getProfile(did, localOnly) - const webUrl = await getWebUrl(handle) + // Load profile and collections (local only for admin, remote for others) + const [profile, webUrl, collections] = await Promise.all([ + getProfile(did, localOnly), + getWebUrl(handle), + describeRepo(did) + ]) // Load posts (local only for admin, remote for others) const posts = await getPosts(did, config.collection, localOnly) @@ -164,15 +169,15 @@ async function render(route: Route): Promise { (route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog') 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 const loggedInDid = getLoggedInDid() 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 let currentRecord: { uri: string; cid: string; value: unknown } | null = null @@ -195,16 +200,14 @@ async function render(route: Route): Promise { html += `` } else if (route.type === 'service' && route.service) { - // AT-Browser: Service collections list - const collections = await describeRepo(did) + // AT-Browser: Service collections list (use pre-loaded collections) const filtered = filterCollectionsByService(collections, route.service) html += `
${renderCollectionList(filtered, handle, route.service)}
` html += `` } 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 collections = await describeRepo(did) html += `
` html += renderServerInfo(did, pds) @@ -228,6 +231,13 @@ async function render(route: Route): Promise { html += `
${renderPostForm(config.collection)}
` html += `` + } else if (route.type === 'card') { + // Card collection page + const cardCollection = config.cardCollection || 'ai.syui.card.user' + const cards = await getCards(did, cardCollection) + html += `
${renderCardPage(cards, handle, cardCollection)}
` + html += `` + } else if (route.type === 'chat') { // Chat list page - show threads started by this user if (!config.bot) { @@ -299,8 +309,7 @@ async function render(route: Route): Promise { } } else { - // User page: compact collection buttons + posts - const collections = await describeRepo(did) + // User page: compact collection buttons + posts (use pre-loaded collections) html += `
${renderCollectionButtons(collections, handle)}
` // Language selector above content diff --git a/src/web/styles/card.css b/src/web/styles/card.css new file mode 100644 index 0000000..5378f97 --- /dev/null +++ b/src/web/styles/card.css @@ -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); + } +} diff --git a/src/web/types.ts b/src/web/types.ts index 3294129..9efdddf 100644 --- a/src/web/types.ts +++ b/src/web/types.ts @@ -11,6 +11,7 @@ export interface AppConfig { bot?: BotConfig collection: string chatCollection?: string + cardCollection?: string network: string color: 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 +}