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 ( + +
+ {cardInfo.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+
+ ); + } + + // Detailed view - all information return ( = ({ card, isRevealing = false }) => { CP: {card.cp} +
+ {cardInfo.name} { + (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 ( +
+
Loading card data...
+
+ ); + } + + 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 ( +
+
+

ai.card マスターリスト

+

全カード・全レアリティパターン表示

+

データソース: https://git.syui.ai/ai/ai/raw/branch/main/ai.json

+
+ +
+ {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