diff --git a/api/app/routes/cards.py b/api/app/routes/cards.py
index e4d6b01..1f3b2ce 100644
--- a/api/app/routes/cards.py
+++ b/api/app/routes/cards.py
@@ -1,10 +1,11 @@
"""Card-related API routes"""
-from typing import List
+from typing import List, Dict
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.card import Card, CardDraw, CardDrawResult
from app.services.gacha import GachaService
+from app.services.card_master import card_master_service
from app.repositories.user import UserRepository
from app.repositories.card import CardRepository, UniqueCardRepository
from app.db.base import get_session
@@ -157,4 +158,16 @@ async def get_gacha_stats(db: AsyncSession = Depends(get_session)):
}
except Exception as e:
- raise HTTPException(status_code=500, detail=f"Statistics error: {str(e)}")
\ No newline at end of file
+ raise HTTPException(status_code=500, detail=f"Statistics error: {str(e)}")
+
+
+@router.get("/master", response_model=List[Dict])
+async def get_card_master_data():
+ """
+ 全カードマスターデータを取得(ai.jsonから)
+ """
+ try:
+ cards = card_master_service.get_all_cards()
+ return cards
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to get card master data: {str(e)}")
\ No newline at end of file
diff --git a/api/app/services/card_master.py b/api/app/services/card_master.py
new file mode 100644
index 0000000..bc268e0
--- /dev/null
+++ b/api/app/services/card_master.py
@@ -0,0 +1,142 @@
+"""
+Card master data fetcher from external ai.json
+"""
+import httpx
+import json
+from typing import Dict, List, Optional
+from functools import lru_cache
+import logging
+
+logger = logging.getLogger(__name__)
+
+CARD_MASTER_URL = "https://git.syui.ai/ai/ai/raw/branch/main/ai.json"
+
+# Default CP ranges for cards (matching existing gacha.py values)
+DEFAULT_CP_RANGES = {
+ 0: (10, 100),
+ 1: (20, 120),
+ 2: (30, 130),
+ 3: (40, 140),
+ 4: (50, 150),
+ 5: (25, 125),
+ 6: (15, 115),
+ 7: (60, 160),
+ 8: (80, 180),
+ 9: (70, 170),
+ 10: (90, 190),
+ 11: (35, 135),
+ 12: (65, 165),
+ 13: (75, 175),
+ 14: (100, 200),
+ 15: (85, 185),
+ 135: (95, 195), # world card
+}
+
+
+class CardMasterService:
+ def __init__(self):
+ self._cache = None
+ self._cache_time = 0
+ self._cache_duration = 3600 # 1 hour cache
+
+ @lru_cache(maxsize=1)
+ def fetch_card_master_data(self) -> Optional[Dict]:
+ """Fetch card master data from external source"""
+ try:
+ response = httpx.get(CARD_MASTER_URL, timeout=10.0)
+ response.raise_for_status()
+ data = response.json()
+ return data
+ except Exception as e:
+ logger.error(f"Failed to fetch card master data: {e}")
+ return None
+
+ def get_card_info(self) -> Dict[int, Dict]:
+ """Get card information in the format expected by gacha service"""
+ master_data = self.fetch_card_master_data()
+
+ if not master_data:
+ # Fallback to hardcoded data
+ return self._get_fallback_card_info()
+
+ try:
+ cards = master_data.get("ai", {}).get("card", {}).get("cards", [])
+ card_info = {}
+
+ for card in cards:
+ card_id = card.get("id")
+ if card_id is not None:
+ # Use name from JSON, fallback to English name
+ name = card.get("name", f"card_{card_id}")
+
+ # Get CP range from defaults
+ cp_range = DEFAULT_CP_RANGES.get(card_id, (50, 150))
+
+ card_info[card_id] = {
+ "name": name,
+ "base_cp_range": cp_range,
+ "ja_name": card.get("lang", {}).get("ja", {}).get("name", name),
+ "description": card.get("lang", {}).get("ja", {}).get("text", "")
+ }
+
+ return card_info
+
+ except Exception as e:
+ logger.error(f"Failed to parse card master data: {e}")
+ return self._get_fallback_card_info()
+
+ def _get_fallback_card_info(self) -> Dict[int, Dict]:
+ """Fallback card info if external source fails"""
+ return {
+ 0: {"name": "ai", "base_cp_range": (10, 100)},
+ 1: {"name": "dream", "base_cp_range": (20, 120)},
+ 2: {"name": "radiance", "base_cp_range": (30, 130)},
+ 3: {"name": "neutron", "base_cp_range": (40, 140)},
+ 4: {"name": "sun", "base_cp_range": (50, 150)},
+ 5: {"name": "night", "base_cp_range": (25, 125)},
+ 6: {"name": "snow", "base_cp_range": (15, 115)},
+ 7: {"name": "thunder", "base_cp_range": (60, 160)},
+ 8: {"name": "ultimate", "base_cp_range": (80, 180)},
+ 9: {"name": "sword", "base_cp_range": (70, 170)},
+ 10: {"name": "destruction", "base_cp_range": (90, 190)},
+ 11: {"name": "earth", "base_cp_range": (35, 135)},
+ 12: {"name": "galaxy", "base_cp_range": (65, 165)},
+ 13: {"name": "create", "base_cp_range": (75, 175)},
+ 14: {"name": "supernova", "base_cp_range": (100, 200)},
+ 15: {"name": "world", "base_cp_range": (85, 185)},
+ }
+
+ def get_all_cards(self) -> List[Dict]:
+ """Get all cards with full information"""
+ master_data = self.fetch_card_master_data()
+
+ if not master_data:
+ return []
+
+ try:
+ cards = master_data.get("ai", {}).get("card", {}).get("cards", [])
+ result = []
+
+ for card in cards:
+ card_id = card.get("id")
+ if card_id is not None:
+ cp_range = DEFAULT_CP_RANGES.get(card_id, (50, 150))
+
+ result.append({
+ "id": card_id,
+ "name": card.get("name", f"card_{card_id}"),
+ "ja_name": card.get("lang", {}).get("ja", {}).get("name", ""),
+ "description": card.get("lang", {}).get("ja", {}).get("text", ""),
+ "base_cp_min": cp_range[0],
+ "base_cp_max": cp_range[1]
+ })
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Failed to get all cards: {e}")
+ return []
+
+
+# Singleton instance
+card_master_service = CardMasterService()
\ No newline at end of file
diff --git a/api/app/services/gacha.py b/api/app/services/gacha.py
index 1697b30..7fc9de9 100644
--- a/api/app/services/gacha.py
+++ b/api/app/services/gacha.py
@@ -10,36 +10,19 @@ from app.models.card import Card, CardRarity
from app.repositories.user import UserRepository
from app.repositories.card import CardRepository, UniqueCardRepository
from app.db.models import DrawHistory
+from app.services.card_master import card_master_service
class GachaService:
"""ガチャシステムのサービスクラス"""
- # カード基本情報(ai.jsonから)
- CARD_INFO = {
- 0: {"name": "ai", "base_cp_range": (10, 100)},
- 1: {"name": "dream", "base_cp_range": (20, 120)},
- 2: {"name": "radiance", "base_cp_range": (30, 130)},
- 3: {"name": "neutron", "base_cp_range": (40, 140)},
- 4: {"name": "sun", "base_cp_range": (50, 150)},
- 5: {"name": "night", "base_cp_range": (25, 125)},
- 6: {"name": "snow", "base_cp_range": (15, 115)},
- 7: {"name": "thunder", "base_cp_range": (60, 160)},
- 8: {"name": "ultimate", "base_cp_range": (80, 180)},
- 9: {"name": "sword", "base_cp_range": (70, 170)},
- 10: {"name": "destruction", "base_cp_range": (90, 190)},
- 11: {"name": "earth", "base_cp_range": (35, 135)},
- 12: {"name": "galaxy", "base_cp_range": (65, 165)},
- 13: {"name": "create", "base_cp_range": (75, 175)},
- 14: {"name": "supernova", "base_cp_range": (100, 200)},
- 15: {"name": "world", "base_cp_range": (85, 185)},
- }
-
def __init__(self, session: AsyncSession):
self.session = session
self.user_repo = UserRepository(session)
self.card_repo = CardRepository(session)
self.unique_repo = UniqueCardRepository(session)
+ # Load card info from external source
+ self.CARD_INFO = card_master_service.get_card_info()
async def draw_card(self, user_did: str, is_paid: bool = False) -> Tuple[Card, bool]:
"""
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 8dda681..bd9d19e 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -387,7 +387,7 @@ Refresh Token: ${agent.session.refreshJwt?.substring(0, 30) || 'N/A'}...
コレクション
{userCards.map((card, index) => (
-
+
))}
{userCards.length === 0 && (
diff --git a/web/src/components/Card.tsx b/web/src/components/Card.tsx
index 667493f..4d6dc2e 100644
--- a/web/src/components/Card.tsx
+++ b/web/src/components/Card.tsx
@@ -6,6 +6,7 @@ import '../styles/Card.css';
interface CardProps {
card: CardType;
isRevealing?: boolean;
+ detailed?: boolean;
}
const CARD_INFO: Record = {
@@ -27,8 +28,9 @@ const CARD_INFO: Record = {
15: { name: "世界", color: "#54a0ff" },
};
-export const Card: React.FC = ({ card, isRevealing = false }) => {
+export const Card: React.FC = ({ card, isRevealing = false, detailed = false }) => {
const cardInfo = CARD_INFO[card.id] || { name: "Unknown", color: "#666" };
+ const imageUrl = `https://git.syui.ai/ai/card/raw/branch/main/img/${card.id}.webp`;
const getRarityClass = () => {
switch (card.status) {
@@ -45,6 +47,30 @@ export const Card: React.FC = ({ card, isRevealing = false }) => {
}
};
+ if (!detailed) {
+ // Simple view - only image and frame
+ return (
+
+
+

{
+ (e.target as HTMLImageElement).style.display = 'none';
+ }}
+ />
+
+
+ );
+ }
+
+ // Detailed view - all information
return (
= ({ card, isRevealing = false }) => {
CP: {card.cp}
+
+

{
+ (e.target as HTMLImageElement).style.display = 'none';
+ }}
+ />
+
+
{cardInfo.name}
{card.is_unique && (
diff --git a/web/src/components/CardBox.tsx b/web/src/components/CardBox.tsx
index 5545d46..f87df79 100644
--- a/web/src/components/CardBox.tsx
+++ b/web/src/components/CardBox.tsx
@@ -12,6 +12,7 @@ export const CardBox: React.FC
= ({ userDid }) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showJson, setShowJson] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
loadBoxData();
@@ -38,6 +39,26 @@ export const CardBox: React.FC = ({ userDid }) => {
alert('カードボックスへの保存機能は親コンポーネントから実行してください');
};
+ const handleDeleteBox = async () => {
+ if (!window.confirm('カードボックスを削除してもよろしいですか?\nこの操作は取り消せません。')) {
+ return;
+ }
+
+ setIsDeleting(true);
+ setError(null);
+
+ try {
+ await atprotoOAuthService.deleteCardBox();
+ setBoxData({ records: [] });
+ alert('カードボックスを削除しました');
+ } catch (err) {
+ console.error('カードボックス削除エラー:', err);
+ setError(err instanceof Error ? err.message : 'カードボックスの削除に失敗しました');
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
if (loading) {
return (
@@ -75,6 +96,15 @@ export const CardBox: React.FC = ({ userDid }) => {
+ {cards.length > 0 && (
+
+ )}
diff --git a/web/src/components/CardList.tsx b/web/src/components/CardList.tsx
new file mode 100644
index 0000000..89138bd
--- /dev/null
+++ b/web/src/components/CardList.tsx
@@ -0,0 +1,113 @@
+import React, { useState, useEffect } from 'react';
+import { Card } from './Card';
+import { cardApi } from '../services/api';
+import { Card as CardType } from '../types/card';
+import '../styles/CardList.css';
+
+interface CardMasterData {
+ id: number;
+ name: string;
+ ja_name: string;
+ description: string;
+ base_cp_min: number;
+ base_cp_max: number;
+}
+
+export const CardList: React.FC = () => {
+ const [loading, setLoading] = useState(true);
+ const [masterData, setMasterData] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadMasterData();
+ }, []);
+
+ const loadMasterData = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch('http://localhost:8000/api/v1/cards/master');
+ if (!response.ok) {
+ throw new Error('Failed to fetch card master data');
+ }
+ const data = await response.json();
+ setMasterData(data);
+ } catch (err) {
+ console.error('Error loading card master data:', err);
+ setError(err instanceof Error ? err.message : 'Failed to load card data');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Error: {error}
+
+
+ );
+ }
+
+ // Create cards for all rarity patterns
+ const rarityPatterns = ['normal', 'unique'] as const;
+
+ const displayCards: Array<{card: CardType, data: CardMasterData, patternName: string}> = [];
+
+ masterData.forEach(data => {
+ rarityPatterns.forEach(pattern => {
+ const card: CardType = {
+ id: data.id,
+ cp: Math.floor((data.base_cp_min + data.base_cp_max) / 2),
+ status: pattern,
+ skill: null,
+ owner_did: 'sample',
+ obtained_at: new Date().toISOString(),
+ is_unique: pattern === 'unique',
+ unique_id: pattern === 'unique' ? 'sample-unique-id' : null
+ };
+ displayCards.push({
+ card,
+ data,
+ patternName: `${data.id}-${pattern}`
+ });
+ });
+ });
+
+
+ return (
+
+
+
+
+ {displayCards.map(({ card, data, patternName }) => (
+
+
+
+
ID: {data.id}
+
Name: {data.name}
+
日本語名: {data.ja_name}
+
レアリティ: {card.status}
+
CP: {card.cp}
+
CP範囲: {data.base_cp_min}-{data.base_cp_max}
+ {data.description && (
+
{data.description}
+ )}
+
+
+ ))}
+
+
+ );
+};
\ No newline at end of file
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 8a440ff..f8190ef 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import App from './App'
import { OAuthCallbackPage } from './components/OAuthCallbackPage'
+import { CardList } from './components/CardList'
import { OAuthEndpointHandler } from './utils/oauth-endpoints'
// Initialize OAuth endpoint handlers for dynamic client metadata and JWKS
@@ -14,6 +15,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
} />
+ } />
} />
diff --git a/web/src/services/atproto-oauth.ts b/web/src/services/atproto-oauth.ts
index df65f5a..bab5e78 100644
--- a/web/src/services/atproto-oauth.ts
+++ b/web/src/services/atproto-oauth.ts
@@ -504,8 +504,8 @@ class AtprotoOAuthService {
owner_did: card.owner_did,
obtained_at: card.obtained_at,
is_unique: card.is_unique,
- unique_id: card.unique_id,
- img: `https://git.syui.ai/ai/ai/raw/branch/main/img/item/card/${card.id}.webp`
+ unique_id: card.unique_id
+
})),
total_cards: userCards.length,
updated_at: createdAt,
@@ -584,6 +584,38 @@ class AtprotoOAuthService {
}
}
+ // ai.card.boxのコレクションを削除
+ async deleteCardBox(): Promise {
+ // Ensure we have a valid session
+ const sessionInfo = await this.checkSession();
+ if (!sessionInfo) {
+ throw new Error('認証が必要です。ログインしてください。');
+ }
+
+ const did = sessionInfo.did;
+
+ try {
+ console.log('Deleting card box collection...');
+ console.log('Using DID:', did);
+
+ // Ensure we have a fresh agent
+ if (!this.agent) {
+ throw new Error('Agentが初期化されていません。');
+ }
+
+ const response = await this.agent.com.atproto.repo.deleteRecord({
+ repo: did,
+ collection: 'ai.card.box',
+ rkey: 'self'
+ });
+
+ console.log('Card box deleted successfully:', response);
+ } catch (error) {
+ console.error('カードボックス削除エラー:', error);
+ throw error;
+ }
+ }
+
// 手動でトークンを設定(開発・デバッグ用)
setManualTokens(accessJwt: string, refreshJwt: string): void {
console.warn('Manual token setting is not supported with official BrowserOAuthClient');
@@ -613,4 +645,4 @@ class AtprotoOAuthService {
}
export const atprotoOAuthService = new AtprotoOAuthService();
-export type { AtprotoSession };
\ No newline at end of file
+export type { AtprotoSession };
diff --git a/web/src/styles/Card.css b/web/src/styles/Card.css
index 2b30823..eb4cbd5 100644
--- a/web/src/styles/Card.css
+++ b/web/src/styles/Card.css
@@ -1,6 +1,6 @@
.card {
width: 250px;
- height: 350px;
+ height: 380px;
border-radius: 12px;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
border: 2px solid #333;
@@ -87,7 +87,26 @@
justify-content: space-between;
font-size: 14px;
color: #888;
- margin-bottom: 20px;
+ margin-bottom: 10px;
+}
+
+.card-image-container {
+ width: 100%;
+ height: 150px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 15px;
+ overflow: hidden;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.card-image {
+ max-width: 100%;
+ max-height: 100%;
+ object-fit: contain;
+ border-radius: 8px;
}
.card-content {
@@ -148,4 +167,165 @@
0% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); }
50% { box-shadow: 0 0 20px rgba(255, 0, 255, 0.8); }
100% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); }
-}
\ No newline at end of file
+}
+
+/* Simple Card Styles */
+.card-simple {
+ width: 240px;
+ height: auto;
+ background: transparent;
+ border: none;
+ padding: 0;
+}
+
+.card-frame {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 3/4;
+ border-radius: 8px;
+ overflow: hidden;
+ background: #1a1a1a;
+ padding: 25px 25px 30px 25px;
+ border: 3px solid #666;
+ box-sizing: border-box;
+}
+
+/* Normal card - no effects */
+.card-simple.card-normal .card-frame {
+ border-color: #666;
+ background: #1a1a1a;
+}
+
+/* Unique (rare) card - glowing effects */
+.card-simple.card-unique .card-frame {
+ border-color: #ffd700;
+ background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%);
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+}
+
+/* Particle/grainy texture for rare cards */
+.card-simple.card-unique .card-frame::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-image:
+ repeating-radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.1) 0px, transparent 1px, transparent 2px),
+ repeating-radial-gradient(circle at 3px 3px, rgba(255, 215, 0, 0.1) 0px, transparent 2px, transparent 4px);
+ background-size: 20px 20px, 30px 30px;
+ opacity: 0.8;
+ z-index: 1;
+ pointer-events: none;
+}
+
+/* Reflection effect for rare cards */
+.card-simple.card-unique .card-frame::after {
+ content: "";
+ height: 100%;
+ width: 40px;
+ position: absolute;
+ top: -180px;
+ left: 0;
+ background: linear-gradient(90deg,
+ transparent 0%,
+ rgba(255, 215, 0, 0.8) 20%,
+ rgba(255, 255, 0, 0.9) 40%,
+ rgba(255, 223, 0, 1) 50%,
+ rgba(255, 255, 0, 0.9) 60%,
+ rgba(255, 215, 0, 0.8) 80%,
+ transparent 100%
+ );
+ opacity: 0;
+ transform: rotate(45deg);
+ animation: gold-reflection 6s ease-in-out infinite;
+ z-index: 2;
+}
+
+@keyframes gold-reflection {
+ 0% { transform: scale(0) rotate(45deg); opacity: 0; }
+ 15% { transform: scale(0) rotate(45deg); opacity: 0; }
+ 17% { transform: scale(4) rotate(45deg); opacity: 0.8; }
+ 20% { transform: scale(50) rotate(45deg); opacity: 0; }
+ 100% { transform: scale(50) rotate(45deg); opacity: 0; }
+}
+
+/* Glowing backlight effect */
+.card-simple.card-unique {
+ position: relative;
+}
+
+.card-simple.card-unique::after {
+ position: absolute;
+ content: "";
+ top: 5px;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: -1;
+ height: 100%;
+ width: 100%;
+ margin: 0 auto;
+ transform: scale(0.95);
+ filter: blur(15px);
+ background: radial-gradient(ellipse at center, #ffd700 0%, #ffb347 50%, transparent 70%);
+ opacity: 0.6;
+}
+
+/* Glowing border effect for rare cards */
+.card-simple.card-unique .card-frame {
+ box-shadow:
+ 0 0 10px rgba(255, 215, 0, 0.5),
+ inset 0 0 10px rgba(255, 215, 0, 0.1);
+}
+
+
+
+.card-image-simple {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 4px;
+ position: relative;
+ z-index: 1;
+}
+
+.card-cp-bar {
+ width: 100%;
+ height: 50px;
+ background: #333;
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: 12px;
+ margin-bottom: 8px;
+ border: 2px solid #666;
+ position: relative;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.card-simple.card-unique .card-cp-bar {
+ background: linear-gradient(135deg, #2a2a1a 0%, #3a3a2a 50%, #2a2a1a 100%);
+ border-color: #ffd700;
+ box-shadow:
+ 0 0 5px rgba(255, 215, 0, 0.3),
+ inset 0 0 5px rgba(255, 215, 0, 0.1);
+}
+
+
+.cp-value {
+ font-size: 20px;
+ font-weight: bold;
+ color: #fff;
+ text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
+ z-index: 1;
+ position: relative;
+}
+
+
+
diff --git a/web/src/styles/CardBox.css b/web/src/styles/CardBox.css
index a50bc57..2a7c61c 100644
--- a/web/src/styles/CardBox.css
+++ b/web/src/styles/CardBox.css
@@ -51,7 +51,8 @@
.json-button,
.refresh-button,
-.retry-button {
+.retry-button,
+.delete-button {
padding: 8px 16px;
border: none;
border-radius: 8px;
@@ -91,6 +92,22 @@
box-shadow: 0 4px 12px rgba(253, 126, 20, 0.4);
}
+.delete-button {
+ background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
+ color: white;
+}
+
+.delete-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
+}
+
+.delete-button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
.json-display {
background: #f8f9fa;
border: 1px solid #dee2e6;
diff --git a/web/src/styles/CardList.css b/web/src/styles/CardList.css
new file mode 100644
index 0000000..4507634
--- /dev/null
+++ b/web/src/styles/CardList.css
@@ -0,0 +1,170 @@
+.card-list-container {
+ min-height: 100vh;
+ background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
+ padding: 20px;
+}
+
+.card-list-header {
+ text-align: center;
+ margin-bottom: 40px;
+ padding: 20px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.card-list-header h1 {
+ color: #fff;
+ margin: 0 0 10px 0;
+ font-size: 2.5rem;
+}
+
+.card-list-header p {
+ color: #999;
+ margin: 0;
+ font-size: 1.1rem;
+}
+
+.card-list-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 30px;
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.card-list-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 15px;
+}
+
+/* Simple grid layout for user-page style */
+.card-list-simple-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 20px;
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+.card-list-simple-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+}
+
+.info-button {
+ background: linear-gradient(135deg, #333 0%, #555 100%);
+ color: white;
+ border: 2px solid #666;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ transition: all 0.3s ease;
+ width: 100%;
+ max-width: 240px;
+}
+
+.info-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ background: linear-gradient(135deg, #444 0%, #666 100%);
+}
+
+.card-info-details {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ padding: 15px;
+ width: 100%;
+ max-width: 240px;
+ margin-top: 10px;
+}
+
+.card-info-details p {
+ margin: 5px 0;
+ color: #ccc;
+ font-size: 0.85rem;
+ text-align: left;
+}
+
+.card-info-details p strong {
+ color: #fff;
+}
+
+.card-meta {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ padding: 15px;
+ width: 100%;
+ max-width: 250px;
+}
+
+.card-meta p {
+ margin: 5px 0;
+ color: #ccc;
+ font-size: 0.9rem;
+}
+
+.card-meta p:first-child {
+ font-weight: bold;
+ color: #fff;
+}
+
+.card-description {
+ font-size: 0.85rem;
+ color: #999;
+ font-style: italic;
+ margin-top: 8px;
+ line-height: 1.4;
+}
+
+.source-info {
+ font-size: 0.9rem;
+ color: #666;
+ margin-top: 5px;
+}
+
+.loading, .error {
+ text-align: center;
+ padding: 40px;
+ color: #999;
+ font-size: 1.2rem;
+}
+
+.error {
+ color: #ff4757;
+}
+
+button {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 5px;
+ cursor: pointer;
+ font-size: 1rem;
+ margin-top: 20px;
+}
+
+button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+}
+
+@media (max-width: 768px) {
+ .card-list-grid {
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 20px;
+ }
+
+ .card-list-header h1 {
+ font-size: 2rem;
+ }
+}
\ No newline at end of file