add card.admin, rse.admin

This commit is contained in:
2026-01-28 16:04:08 +09:00
parent cfa36633e1
commit c053143db3
23 changed files with 933 additions and 42 deletions

View File

@@ -6,24 +6,55 @@
"main": { "main": {
"type": "record", "type": "record",
"key": "literal:self", "key": "literal:self",
"description": "Card game configuration (admin only)", "description": "Card game configuration and master data (admin only)",
"record": { "record": {
"type": "object", "type": "object",
"required": ["card", "rate", "createdAt", "updatedAt"], "required": ["gacha", "card", "createdAt", "updatedAt"],
"properties": { "properties": {
"card": { "gacha": {
"type": "object", "type": "object",
"required": ["pickup"], "required": ["pickup", "rate"],
"properties": { "properties": {
"pickup": { "type": "integer", "description": "Pickup card ID" } "pickup": { "type": "integer", "description": "Pickup card ID" },
"rate": {
"type": "object",
"required": ["pickup", "rare"],
"properties": {
"pickup": { "type": "integer", "description": "1/n for pickup rate (100 = 1%)" },
"rare": { "type": "integer", "description": "1/n for rare:1 rate (10 = 10%), rare:2 = 1/(n*10), rare:3 = 1/(n*100)" }
}
}
} }
}, },
"rate": { "card": {
"type": "object", "type": "array",
"required": ["pickup", "rare"], "description": "Card master data",
"properties": { "items": {
"pickup": { "type": "integer", "description": "1/n for pickup rate (100 = 1%)" }, "type": "object",
"rare": { "type": "integer", "description": "1/n for rare:1 rate (10 = 10%), rare:2 = 1/(n*10), rare:3 = 1/(n*100)" } "required": ["id", "character", "name", "text", "cp", "effect"],
"properties": {
"id": { "type": "integer", "description": "Card ID" },
"character": { "type": "integer", "description": "Associated character ID" },
"name": {
"type": "object",
"required": ["ja", "en"],
"properties": {
"ja": { "type": "string" },
"en": { "type": "string" }
}
},
"text": {
"type": "object",
"required": ["ja", "en"],
"properties": {
"ja": { "type": "string" },
"en": { "type": "string" }
}
},
"cp": { "type": "string", "description": "CP type (status, time, damage)" },
"effect": { "type": "string", "description": "Effect type (status, fly, mode, damage)" },
"key": { "type": "string", "description": "Key binding (R1, L1, Y, X, etc.)" }
}
} }
}, },
"createdAt": { "type": "string", "format": "datetime" }, "createdAt": { "type": "string", "format": "datetime" },

View File

@@ -21,10 +21,12 @@
"id": { "type": "integer", "description": "Ability ID" }, "id": { "type": "integer", "description": "Ability ID" },
"name": { "type": "string", "description": "Ability name (ai, quark, neutron, atom, sun)" }, "name": { "type": "string", "description": "Ability name (ai, quark, neutron, atom, sun)" },
"kind": { "type": "string", "description": "Attribute type (consciousness, matter)" }, "kind": { "type": "string", "description": "Attribute type (consciousness, matter)" },
"color": { "type": "string", "description": "Color code (e.g., #ffd700)" },
"level": { "type": "integer", "description": "Hierarchy level (0=fundamental)" }, "level": { "type": "integer", "description": "Hierarchy level (0=fundamental)" },
"relation": { "type": "array", "items": { "type": "integer" }, "description": "Advantage IDs" }, "relation": { "type": "array", "items": { "type": "integer" }, "description": "Advantage IDs" },
"weakness": { "type": "array", "items": { "type": "integer" }, "description": "Weakness IDs" }, "weakness": { "type": "array", "items": { "type": "integer" }, "description": "Weakness IDs" },
"multiplier": { "type": "integer", "description": "Damage multiplier percent (e.g., 150 = 1.5x)" } "multiplier": { "type": "integer", "description": "Damage multiplier percent (e.g., 150 = 1.5x)" },
"phantom": { "type": "boolean", "description": "Whether this ability is phantom/lost" }
} }
} }
}, },
@@ -55,6 +57,26 @@
} }
} }
}, },
"item": {
"type": "array",
"description": "Item definitions",
"items": {
"type": "object",
"required": ["id", "name", "text"],
"properties": {
"id": { "type": "integer", "description": "Item ID" },
"name": { "type": "string", "description": "Item name" },
"text": {
"type": "object",
"description": "Item description (localized)",
"properties": {
"en": { "type": "string", "description": "English text" },
"ja": { "type": "string", "description": "Japanese text" }
}
}
}
}
},
"collection": { "collection": {
"type": "array", "type": "array",
"description": "ATProto collection definitions", "description": "ATProto collection definitions",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/card/201.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 32 KiB

BIN
public/card/301.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,31 @@
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.card.admin/self",
"cid": "",
"value": {
"$type": "ai.syui.card.admin",
"gacha": {
"pickup": 13,
"rate": {
"rare": 10,
"pickup": 100
}
},
"card": [
{ "id": 0, "character": 0, "name": { "ja": "アイ", "en": "ai" }, "text": { "ja": "アイの基礎ステータスは、このカードのcpを基準にアップする。", "en": "This card's CP is referenced for the character's base status" }, "cp": "status", "effect": "status" },
{ "id": 1, "character": 0, "name": { "ja": "夢幻", "en": "dream" }, "text": { "ja": "質量解放により星間空間を自由に移動する。飛行時間は、このカードのcpを基準に延長される。", "en": "This card's CP is referenced for flight duration" }, "cp": "time", "effect": "fly", "key": "R1" },
{ "id": 2, "character": 0, "name": { "ja": "光彩", "en": "shiny" }, "text": { "ja": "モードと呼ばれる変身を行う。スキル発動時、動作スピードがアップする。継続時間及び効果は、このカードのcpを基準にアップする。", "en": "This card's CP is referenced for transformation duration" }, "cp": "time", "effect": "mode", "key": "L1" },
{ "id": 3, "character": 0, "name": { "ja": "中性子", "en": "neutron" }, "text": { "ja": "手のひらに中性子星を作り出して前方に放つ。ヒットした敵に物理と属性の大ダメージを与える。敵に与える物理ダメージは、このカードのcpを基準にアップする。", "en": "This card's CP is referenced for physical damage" }, "cp": "damage", "effect": "damage" , "key": "Y" },
{ "id": 13, "character": 0, "name": { "ja": "創造", "en": "create" }, "text": { "ja": "自身の周囲に真空を作り出し、物理と属性の範囲ダメージを与える。敵に与える属性ダメージは、このカードのcpを基準にアップする。", "en": "This card's CP is referenced for elemental damage" }, "cp": "damage", "effect": "damage", "key": "X" },
{ "id": 100, "character": 1, "name": { "ja": "ユイ", "en": "yui" }, "text": { "ja": "", "en": "" }, "cp": "", "effect": "" },
{ "id": 101, "character": 1, "name": { "ja": "", "en": "" }, "text": { "ja": "", "en": "" }, "cp": "", "effect": "" },
{ "id": 200, "character": 2, "name": { "ja": "ドラゴン", "en": "dragon" }, "text": { "ja": "", "en": "" }, "cp": "", "effect": "" },
{ "id": 201, "character": 2, "name": { "ja": "", "en": "" }, "text": { "ja": "", "en": "" }, "cp": "", "effect": "" },
{ "id": 300, "character": 3, "name": { "ja": "ロボット", "en": "robot" }, "text": { "ja": "", "en": "" }, "cp": "", "effect": "" },
{ "id": 301, "character": 3, "name": { "ja": "", "en": "" }, "text": { "ja": "", "en": "" }, "cp": "", "effect": "" },
{ "id": 400, "character": 4, "name": { "ja": "こんにゃーん", "en": "conyan" }, "text": { "ja": "", "en": "" }, "cp": "", "effect": "" },
{ "id": 401, "character": 4, "name": { "ja": "", "en": "" }, "text": { "ja": "", "en": "" }, "cp": "", "effect": "" }
],
"createdAt": "2026-01-25T09:02:20.000Z",
"updatedAt": "2026-01-25T09:02:20.000Z"
}
}

View File

@@ -0,0 +1,46 @@
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.card.user/self",
"cid": "bafyreicjbb6qdl77sdsma5ep674znlgvnhenqqogppx3mmawlpengoxol4",
"value": {
"card": [
{
"cp": 464,
"id": 1,
"cid": "5w4xmnyzukh62",
"rare": 1,
"unique": false
},
{
"cp": 634,
"id": 0,
"cid": "6lzqdddvggzcs",
"rare": 0,
"unique": false
},
{
"cp": 2,
"id": 2,
"cid": "5cjxquqzywetg",
"rare": 0,
"unique": false
},
{
"cp": 3,
"id": 3,
"cid": "bmdlhjwedjlmu",
"rare": 0,
"unique": false
},
{
"cp": 43,
"id": 13,
"cid": "25kp6sbgcdug7",
"rare": 4,
"unique": true
}
],
"$type": "ai.syui.card.user",
"createdAt": "2026-01-28T17:10:10.535Z",
"updatedAt": "2026-01-28T17:10:10.535Z"
}
}

View File

@@ -0,0 +1,285 @@
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.rse.admin/self",
"cid": "bafyreihgv33l3kdbpghum3ul5uugscfoaooyyx7nu3eaiuge4l6pru6je4",
"value": {
"item": [
{ "id": 0, "name": "a", "text": { "en": "world", "ja": "世界" } },
{ "id": 1, "name": "i", "text": { "en": "The Pillar of Creation at the center of the galaxy stands tall in a place called the Eternal Stone, which is said to tolerate no change. The three attributes of gold, silver, and copper are engraved on it.", "ja": "銀河の中心にある創造の柱。そこにそびえ立つ永遠の石には金、銀、銅の3つの属性が刻み込まれている" } },
{ "id": 2, "name": "gordbox", "text": { "en": "The first color in this world", "ja": "この世界の最初の色" } },
{ "id": 3, "name": "silverbox", "text": { "en": "The second color in this world", "ja": "この世界の2番目の色" } },
{ "id": 4, "name": "bluebox", "text": { "en": "The third color in this world. It was once red, but was rewritten by someone", "ja": "この世界の3番目の色。かつては赤だったが、何者かに書き換えられた" } },
{ "id": 5, "name": "copperbox", "text": { "en": "The Third Lost Color of the World", "ja": "この世界から失われた3番目の色" } }
],
"$type": "ai.syui.rse.admin",
"system": [
{
"id": 0,
"name": "ai",
"domain": "ability"
},
{
"id": 1,
"name": "yui",
"domain": "unique"
},
{
"id": 2,
"name": "at",
"domain": "account"
},
{
"id": 3,
"name": "world",
"domain": "planet"
},
{
"id": 4,
"name": "dream",
"domain": "origin"
}
],
"ability": [
{
"id": 0,
"kind": "i",
"name": "ai",
"color": "#000",
"level": 0,
"relation": [
0,
1,
2,
3,
4,
5,
6,
7,
8
],
"weakness": [
1
],
"multiplier": 150
},
{
"id": 1,
"kind": "unique",
"name": "yui",
"color": "#fff",
"level": 0,
"relation": [],
"weakness": [],
"multiplier": 100
},
{
"id": 2,
"kind": "phantom",
"name": "axion",
"color": "#8b00ff",
"level": 1,
"relation": [
3,
5,
6
],
"weakness": [
4
],
"multiplier": 150
},
{
"id": 3,
"kind": "phantom",
"name": "baryon",
"color": "#0066cc",
"level": 1,
"relation": [
4,
6,
7
],
"weakness": [
2
],
"multiplier": 150
},
{
"id": 4,
"kind": "phantom",
"name": "photon",
"color": "#ffff00",
"level": 1,
"relation": [
2,
7,
8
],
"weakness": [
3
],
"multiplier": 150
},
{
"id": 5,
"kind": "matter",
"name": "quark",
"color": "#ff0000",
"level": 2,
"relation": [
6
],
"weakness": [
0,
2
],
"multiplier": 150
},
{
"id": 6,
"kind": "matter",
"name": "neutron",
"color": "#808080",
"level": 2,
"relation": [
7
],
"weakness": [
5,
2,
3
],
"multiplier": 150
},
{
"id": 7,
"kind": "matter",
"name": "atom",
"color": "#00cc66",
"level": 2,
"relation": [
8
],
"weakness": [
6,
3,
4
],
"multiplier": 150
},
{
"id": 8,
"kind": "matter",
"name": "sun",
"color": "#ff8c00",
"level": 2,
"relation": [
5
],
"weakness": [
7,
4
],
"multiplier": 150
},
{
"id": 9,
"kind": "element",
"name": "gold",
"color": "#ffd700",
"level": 3,
"relation": [
10
],
"weakness": [
11,
12
],
"multiplier": 150
},
{
"id": 10,
"kind": "element",
"name": "silver",
"color": "#c0c0c0",
"level": 3,
"relation": [
11,
12
],
"weakness": [
9
],
"multiplier": 150
},
{
"id": 11,
"kind": "element",
"name": "copper",
"color": "#b87333",
"level": 3,
"relation": [
9
],
"weakness": [
10
],
"multiplier": 150,
"phantom": true
},
{
"id": 12,
"kind": "element",
"name": "oxygen",
"color": "#87ceeb",
"level": 3,
"relation": [
9
],
"weakness": [
10
],
"multiplier": 150
}
],
"character": [
{
"id": 0,
"mode": 2,
"name": "ai",
"ability": 0
},
{
"id": 1,
"mode": 2,
"name": "yui",
"ability": 1
},
{
"id": 2,
"mode": 0,
"name": "dragon",
"ability": 2
},
{
"id": 3,
"mode": 0,
"name": "robot",
"ability": 5
}
],
"createdAt": "2026-01-27T16:25:09.000Z",
"updatedAt": "2026-01-27T16:25:09.000Z",
"collection": [
{
"id": 0,
"name": "card",
"nsid": "ai.syui.card"
},
{
"id": 1,
"name": "rse",
"nsid": "ai.syui.rse"
}
]
}
}

View File

@@ -0,0 +1,83 @@
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.rse.user/self",
"cid": "bafyreih36zbcu2jx3yqxpjftvagarinwalaiodwxgyi5fqnw4iqkgslmya",
"value": {
"item": [
{
"cp": 0,
"id": 0,
"cid": "0",
"rare": 0,
"unique": true
},
{
"cp": 1,
"id": 1,
"cid": "1",
"rare": 0,
"unique": false
},
{
"cp": 2,
"id": 2,
"cid": "2",
"rare": 0,
"unique": false
},
{
"cp": 3,
"id": 3,
"cid": "3",
"rare": 0,
"unique": false
},
{
"cp": 4,
"id": 4,
"cid": "4",
"rare": 0,
"unique": false
},
{
"cp": 5,
"id": 5,
"cid": "5",
"rare": 0,
"unique": false
}
],
"$type": "ai.syui.rse.user",
"character": [
{
"cp": 0,
"id": 0,
"cid": "0",
"rare": 0,
"unique": true
},
{
"cp": 1,
"id": 1,
"cid": "1",
"rare": 0,
"unique": false
},
{
"cp": 2,
"id": 2,
"cid": "2",
"rare": 0,
"unique": false
},
{
"cp": 3,
"id": 3,
"cid": "3",
"rare": 0,
"unique": false
}
],
"createdAt": "2026-01-28T12:04:27.000Z",
"updatedAt": "2026-01-28T12:04:27.000Z"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/rse/item/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/rse/item/2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/rse/item/3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/rse/item/4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/rse/item/5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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>
` `
} }
@@ -109,13 +156,9 @@ export function renderCardPage(
} }
} }
// Sort by unique first, then rarity (desc), then by id // Sort by id
const sortedGroups = Array.from(cardGroups.values()) const sortedGroups = Array.from(cardGroups.values())
.sort((a, b) => { .sort((a, b) => a.card.id - b.card.id)
if (a.card.unique !== b.card.unique) return a.card.unique ? -1 : 1
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 }) => { const cardsHtml = sortedGroups.map(({ card, count }) => {
return renderCard(card, '/card', count) return renderCard(card, '/card', count)

View File

@@ -1,4 +1,15 @@
// 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 RseAdminItem {
id: number
name: string
text: { ja: string; en: string }
}
export interface RseAdminData {
item: RseAdminItem[]
}
export interface RseItem { export interface RseItem {
id: number id: number
@@ -14,31 +25,135 @@ export interface RseCollection {
updatedAt: string updatedAt: string
} }
// 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 from unique flag // Get rarity class from unique flag
function getRarityClass(item: RseItem): string { function getRarityClass(item: RseItem): string {
if (item.unique) return 'unique' if (item.unique) return 'unique'
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 rarityClass = getRarityClass(item)
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>
` : '' ` : ''
// 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, rseAdminData: RseAdminData | null): string {
const rarityClass = getRarityClass(item)
const effectsHtml = rarityClass ? `
<div class="card-status pattern-${rarityClass}"></div>
<div class="card-status color-${rarityClass}"></div>
` : ''
// Get admin entry for this item
const adminEntry = rseAdminData?.item?.find(i => i.id === item.id)
const name = adminEntry?.name || ''
const text = adminEntry ? getLocalizedText(adminEntry.text) : ''
const infoHtml = (name || text) ? `
<div class="card-info">
<div class="card-info-header">
<span class="card-info-name">${name}</span>
</div>
${text ? `<div class="card-info-text">${text}</div>` : ''}
</div>
` : ''
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="/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>
<div class="card-detail"> <div class="card-detail">
<span class="card-cp">${item.cp}</span> <span class="card-cp">${item.cp}</span>
</div> </div>
${infoHtml}
</div> </div>
` `
} }
@@ -46,7 +161,10 @@ 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,
rseAdminData: RseAdminData | 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 +188,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, rseAdminData)).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 +220,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,105 @@ 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
}
// RSE admin data types
export interface RseAdminItem {
id: number
name: string
text: { ja: string; en: string }
}
export interface RseAdminData {
item: RseAdminItem[]
ability: unknown[]
character: unknown[]
system: unknown[]
collection: unknown[]
createdAt: string
updatedAt: string
}
// Get RSE admin data (ai.syui.rse.admin)
export async function getRseAdmin(did: string): Promise<RseAdminData | null> {
const collection = 'ai.syui.rse.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 RseAdminData
}
} 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 RseAdminData
}
} 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, getRseAdmin, 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,17 @@ 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, rseAdminData] = await Promise.all([
getRse(did),
getCards(did, cardCollection),
getCardAdmin(adminDid),
getRseAdmin(adminDid)
])
const userCards = cards?.card || []
html += `<div id="content">${renderRsePage(rseData, handle, userCards, adminData, rseAdminData)}</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,133 @@
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;
width: 100%;
}
.rse-character-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 100%;
}
.rse-character-main {
display: flex;
justify-content: center;
}
.rse-character-main .card-wrapper {
width: 250px;
max-width: 250px;
}
/* RSE card grid (cards below character) */
.rse-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
width: 100%;
padding: 20px;
background: rgba(128, 128, 128, 0.08);
border-radius: 12px;
}
.rse-card-grid .card-wrapper {
max-width: 180px;
}
.rse-card-grid .card-info-name {
font-size: 11px;
}
.rse-card-grid .card-info-text {
font-size: 12px;
}
.rse-card-grid .card-key-btn {
font-size: 9px;
padding: 2px 8px;
}
/* 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.5;
}
.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);
}
}