diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 493412e..3fce09c 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -10,7 +10,30 @@
"Bash(/Users/syui/ai/log/target/debug/ailog build)",
"Bash(ls:*)",
"Bash(curl:*)",
- "Bash(pkill:*)"
+ "Bash(pkill:*)",
+ "WebFetch(domain:docs.anthropic.com)",
+ "WebFetch(domain:github.com)",
+ "Bash(rm:*)",
+ "Bash(mv:*)",
+ "Bash(cp:*)",
+ "Bash(timeout:*)",
+ "Bash(grep:*)",
+ "Bash(./target/debug/ailog:*)",
+ "Bash(cat:*)",
+ "Bash(npm install)",
+ "Bash(npm run build:*)",
+ "Bash(chmod:*)",
+ "Bash(./scripts/tunnel.sh:*)",
+ "Bash(PRODUCTION=true cargo run -- build)",
+ "Bash(cloudflared tunnel:*)",
+ "Bash(npm install:*)",
+ "Bash(./scripts/build-oauth-partial.zsh:*)",
+ "Bash(./scripts/quick-oauth-update.zsh:*)",
+ "Bash(../target/debug/ailog serve)",
+ "Bash(./scripts/test-oauth.sh:*)",
+ "Bash(./run.zsh:*)",
+ "Bash(npm run dev:*)",
+ "Bash(./target/release/ailog:*)"
],
"deny": []
}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..acf4412
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,62 @@
+name: Deploy ailog
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Rust
+ uses: actions-rs/toolchain@v1
+ with:
+ toolchain: stable
+ profile: minimal
+ override: true
+
+ - name: Cache cargo registry
+ uses: actions/cache@v3
+ with:
+ path: ~/.cargo/registry
+ key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
+
+ - name: Cache cargo index
+ uses: actions/cache@v3
+ with:
+ path: ~/.cargo/git
+ key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
+
+ - name: Cache cargo build
+ uses: actions/cache@v3
+ with:
+ path: target
+ key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
+
+ - name: Build ailog
+ run: cargo build --release
+
+ - name: Generate static site
+ run: |
+ ./target/release/ailog build my-blog
+ touch my-blog/public/.nojekyll
+
+ - name: Setup Cloudflare Pages
+ run: |
+ # Cloudflare Pages用の設定
+ echo '/* /index.html 200' > my-blog/public/_redirects
+ echo 'X-Frame-Options: DENY' > my-blog/public/_headers
+ echo 'X-Content-Type-Options: nosniff' >> my-blog/public/_headers
+
+ - name: Deploy to GitHub Pages
+ uses: peaceiris/actions-gh-pages@v3
+ if: github.ref == 'refs/heads/main'
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./my-blog/public
+ publish_branch: gh-pages
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 9ae5be4..5e143c8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,9 @@
*.swp
*.swo
*~
-.DS_Store
\ No newline at end of file
+.DS_Store
+cloudflare*
+my-blog
+dist
+package-lock.json
+node_modules
diff --git a/Cargo.toml b/Cargo.toml
index b1cdc05..43e893d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,10 @@ authors = ["syui"]
description = "A static blog generator with AI features"
license = "MIT"
+[[bin]]
+name = "ailog"
+path = "src/main.rs"
+
[dependencies]
clap = { version = "4.5", features = ["derive"] }
pulldown-cmark = "0.11"
@@ -32,12 +36,19 @@ axum = "0.7"
tower = "0.5"
tower-http = { version = "0.5", features = ["cors", "fs"] }
hyper = { version = "1.0", features = ["full"] }
+tower-sessions = "0.12"
+jsonwebtoken = "9.2"
+cookie = "0.18"
# Documentation generation dependencies
syn = { version = "2.0", features = ["full", "parsing", "visit"] }
quote = "1.0"
ignore = "0.4"
git2 = "0.18"
regex = "1.0"
+# ATProto and stream monitoring dependencies
+tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
+futures-util = "0.3"
+tungstenite = { version = "0.21", features = ["native-tls"] }
[dev-dependencies]
tempfile = "3.14"
\ No newline at end of file
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
new file mode 100644
index 0000000..04073f6
--- /dev/null
+++ b/DEPLOYMENT.md
@@ -0,0 +1,150 @@
+# ai.log Deployment Guide
+
+## 🌐 Cloudflare Tunnel Setup
+
+ATProto OAuth requires HTTPS for proper CORS handling. Use Cloudflare Tunnel for secure deployment.
+
+### Prerequisites
+
+1. **Install cloudflared**:
+ ```bash
+ brew install cloudflared
+ ```
+
+2. **Login and create tunnel** (if not already done):
+ ```bash
+ cloudflared tunnel login
+ cloudflared tunnel create ailog
+ ```
+
+3. **Configure DNS**:
+ - Add a CNAME record: `log.syui.ai` → `[tunnel-id].cfargotunnel.com`
+
+### Configuration Files
+
+#### `cloudflared-config.yml`
+```yaml
+tunnel: a6813327-f880-485d-a9d1-376e6e3df8ad
+credentials-file: /Users/syui/.cloudflared/a6813327-f880-485d-a9d1-376e6e3df8ad.json
+
+ingress:
+ - hostname: log.syui.ai
+ service: http://localhost:8080
+ originRequest:
+ noHappyEyeballs: true
+ - service: http_status:404
+```
+
+#### Production Client Metadata
+`static/client-metadata-prod.json`:
+```json
+{
+ "client_id": "https://log.syui.ai/client-metadata.json",
+ "client_name": "ai.log Blog Comment System",
+ "client_uri": "https://log.syui.ai",
+ "redirect_uris": ["https://log.syui.ai/"],
+ "grant_types": ["authorization_code"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none",
+ "application_type": "web"
+}
+```
+
+### Deployment Commands
+
+#### Quick Start
+```bash
+# All-in-one deployment
+./scripts/tunnel.sh
+```
+
+#### Manual Steps
+```bash
+# 1. Build for production
+PRODUCTION=true cargo run -- build
+
+# 2. Start local server
+cargo run -- serve --port 8080 &
+
+# 3. Start tunnel
+cloudflared tunnel --config cloudflared-config.yml run
+```
+
+### Environment Detection
+
+The system automatically detects environment:
+
+- **Development** (`localhost:8080`): Uses local client-metadata.json
+- **Production** (`log.syui.ai`): Uses HTTPS client-metadata.json
+
+### CORS Resolution
+
+✅ **With Cloudflare Tunnel**:
+- HTTPS domain: `https://log.syui.ai`
+- Valid SSL certificate
+- Proper CORS headers
+- ATProto OAuth works correctly
+
+❌ **With localhost**:
+- HTTP only: `http://localhost:8080`
+- CORS restrictions
+- ATProto OAuth may fail
+
+### Troubleshooting
+
+#### ATProto OAuth Errors
+```javascript
+// Check client metadata URL in browser console
+console.log('Environment:', window.location.hostname);
+console.log('Client ID:', clientId);
+```
+
+#### Tunnel Connection Issues
+```bash
+# Check tunnel status
+cloudflared tunnel info ailog
+
+# Test local server
+curl http://localhost:8080/client-metadata.json
+```
+
+#### DNS Propagation
+```bash
+# Check DNS resolution
+dig log.syui.ai
+nslookup log.syui.ai
+```
+
+### Security Notes
+
+- **Client metadata** is publicly accessible (required by ATProto)
+- **Credentials file** contains tunnel secrets (keep secure)
+- **HTTPS only** for production OAuth
+- **Domain validation** by ATProto servers
+
+### Integration with ai.ai Ecosystem
+
+This deployment enables:
+- **ai.log**: Comment system with ATProto authentication
+- **ai.card**: Shared OAuth widget
+- **ai.gpt**: Memory synchronization via ATProto
+- **ai.verse**: Future 3D world integration
+
+### Monitoring
+
+```bash
+# Monitor tunnel logs
+cloudflared tunnel --config cloudflared-config.yml run --loglevel debug
+
+# Monitor blog server
+tail -f /path/to/blog/logs
+
+# Check ATProto connectivity
+curl -I https://log.syui.ai/client-metadata.json
+```
+
+---
+
+**🔗 Live URL**: https://log.syui.ai
+**📊 Status**: Production Ready
+**🌐 ATProto**: OAuth Enabled
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..89ac7f0
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,32 @@
+# Multi-stage build for ailog
+FROM rust:1.75 as builder
+
+WORKDIR /usr/src/app
+COPY Cargo.toml Cargo.lock ./
+COPY src ./src
+
+RUN cargo build --release
+
+FROM debian:bookworm-slim
+
+# Install runtime dependencies
+RUN apt-get update && apt-get install -y \
+ ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+# Copy the binary
+COPY --from=builder /usr/src/app/target/release/ailog /usr/local/bin/ailog
+
+# Copy blog content
+COPY my-blog ./blog
+
+# Build static site
+RUN ailog build blog
+
+# Expose port
+EXPOSE 8080
+
+# Run server
+CMD ["ailog", "serve", "blog"]
\ No newline at end of file
diff --git a/README.md b/README.md
index 1d3fed9..a768067 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,92 @@
# ai.log
-A Rust-based static blog generator with AI integration capabilities.
+AI-powered static blog generator with ATProto integration, part of the ai.ai ecosystem.
-## Overview
+## 🚀 Quick Start
-ai.log is part of the ai ecosystem - a static site generator that creates blogs with built-in AI features for content enhancement and atproto integration. The system follows the yui system principles with dual-layer MCP architecture.
+```bash
+# Development
+./run.zsh serve
+
+# Production (with Cloudflare Tunnel)
+./run.zsh tunnel
+```
+
+## 📋 Commands
+
+| Command | Description |
+|---------|-------------|
+| `./run.zsh serve` | Start development server |
+| `./run.zsh build` | Build blog only |
+| `./run.zsh oauth` | Copy OAuth files to static/ |
+| `./run.zsh all` | Build OAuth + blog |
+| `./run.zsh clean` | Clean all build artifacts |
+| `./run.zsh tunnel` | Production deployment |
+| `./run.zsh c` | Enable Cloudflare tunnel (xxxcard.syui.ai) for OAuth |
+| `./run.zsh o` | Start OAuth web server (port:4173 = xxxcard.syui.ai) |
+| `./run.zsh co` | Start comment system (ATProto stream monitor) |
+
+## 🏗️ Architecture (Pure Rust + HTML + JS)
+
+```
+ai.log/
+├── oauth/ # 🎯 OAuth files (protected)
+│ ├── oauth-widget-simple.js # Self-contained OAuth widget
+│ ├── oauth-simple.html # OAuth authentication page
+│ ├── client-metadata.json # ATProto configuration
+│ └── README.md # Usage guide
+├── my-blog/ # Blog content and templates
+│ ├── content/posts/ # Markdown blog posts
+│ ├── templates/ # Tera templates
+│ ├── static/ # Static assets (OAuth copied here)
+│ └── public/ # Generated site (build output)
+├── src/ # Rust blog generator
+├── scripts/ # Build and deployment scripts
+└── run.zsh # 🎯 Main build script
+```
+
+### ✅ Node.js Dependencies Eliminated
+- ❌ `package.json` - Removed
+- ❌ `node_modules/` - Removed
+- ❌ `npm run build` - Not needed
+- ✅ Pure JavaScript OAuth implementation
+- ✅ CDN-free, self-contained code
+- ✅ Rust-only build process
+
+---
+
+## 📖 Original Features
+
+[](https://www.rust-lang.org/)
+[](https://opensource.org/licenses/MIT)
+
+## 概要
+
+ai.logは、[Anthropic Docs](https://docs.anthropic.com/)にインスパイアされたモダンなインターフェースを持つ、次世代静的ブログジェネレーターです。ai.gptとの深い統合、ローカルAI機能、atproto OAuth連携により、従来のブログシステムを超えた体験を提供します。
+
+## 主な特徴
+
+### 🎨 モダンインターフェース
+- **Anthropic Docs風デザイン**: プロフェッショナルで読みやすい
+- **Timeline形式**: BlueskyライクなタイムラインUI
+- **自動TOC**: 右サイドバーに目次を自動生成
+- **レスポンシブ**: モバイル・デスクトップ対応
+
+### 🤖 AI統合機能
+- **Ask AI**: ローカルLLM(Ollama)による質問応答
+- **自動翻訳**: 日本語↔英語の自動生成
+- **AI記事強化**: コンテンツの自動改善
+- **AIコメント**: 記事への一言コメント生成
+
+### 🌐 分散SNS連携
+- **atproto OAuth**: Blueskyアカウントでログイン
+- **コメントシステム**: 分散SNSコメント
+- **データ主権**: ユーザーがデータを所有
+
+### 🔗 エコシステム統合
+- **ai.gpt**: ドキュメント同期・AI機能連携
+- **MCP Server**: ai.gptからの操作をサポート
+- **ai.wiki**: 自動ドキュメント同期
## Architecture
@@ -161,9 +243,12 @@ Generate comprehensive documentation and translate content:
- Collection storage in atproto
### Comment System
-- atproto account login
-- Distributed comment storage
-- Real-time comment synchronization
+- **ATProto Stream Monitoring**: Real-time Jetstream connection monitoring
+- **Collection Tracking**: Monitors `ai.syui.log` collection for new comments
+- **User Management**: Automatically adds commenting users to `ai.syui.log.user` collection
+- **Comment Display**: Fetches and displays comments from registered users
+- **OAuth Integration**: atproto account login via Cloudflare tunnel
+- **Distributed Storage**: Comments stored in user-owned atproto collections
## Build & Deploy
@@ -201,11 +286,14 @@ Generate comprehensive documentation and translate content:
- **🌍 Ollama-powered translation system**
- **🚀 Complete MCP integration with ai.gpt**
- **📄 Markdown-aware translation preserving structure**
+- **💬 ATProto comment system with Jetstream monitoring**
+- **🔄 Real-time comment collection and user management**
+- **🔐 OAuth 2.1 integration with Cloudflare tunnel**
- Test blog with sample content and styling
### 🚧 In Progress
- AI-powered content enhancement pipeline
-- atproto OAuth implementation
+- Advanced comment moderation system
### 📋 Planned Features
- Advanced template customization
diff --git a/aicard-web-oauth/index.html b/aicard-web-oauth/index.html
new file mode 100644
index 0000000..7652c5a
--- /dev/null
+++ b/aicard-web-oauth/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ ai.card
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/aicard-web-oauth/package.json b/aicard-web-oauth/package.json
new file mode 100644
index 0000000..4cff1b2
--- /dev/null
+++ b/aicard-web-oauth/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "aicard",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "vite --mode development",
+ "build": "vite build --mode production",
+ "build:dev": "vite build --mode development",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@atproto/api": "^0.15.12",
+ "@atproto/did": "^0.1.5",
+ "@atproto/identity": "^0.4.8",
+ "@atproto/oauth-client-browser": "^0.3.19",
+ "@atproto/xrpc": "^0.7.0",
+ "axios": "^1.6.2",
+ "framer-motion": "^10.16.16",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^7.6.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.45",
+ "@types/react-dom": "^18.2.18",
+ "@vitejs/plugin-react": "^4.2.1",
+ "typescript": "^5.3.3",
+ "vite": "^5.0.10"
+ }
+}
diff --git a/aicard-web-oauth/public/.well-known/jwks.json b/aicard-web-oauth/public/.well-known/jwks.json
new file mode 100644
index 0000000..d8a3f40
--- /dev/null
+++ b/aicard-web-oauth/public/.well-known/jwks.json
@@ -0,0 +1,14 @@
+{
+ "keys": [
+ {
+ "kty": "EC",
+ "crv": "P-256",
+ "x": "mock_x_coordinate_base64url",
+ "y": "mock_y_coordinate_base64url",
+ "d": "mock_private_key_base64url",
+ "use": "sig",
+ "kid": "ai-card-oauth-key-1",
+ "alg": "ES256"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/public/client-metadata.json b/aicard-web-oauth/public/client-metadata.json
new file mode 100644
index 0000000..4d79d3d
--- /dev/null
+++ b/aicard-web-oauth/public/client-metadata.json
@@ -0,0 +1,24 @@
+{
+ "client_id": "https://xxxcard.syui.ai/client-metadata.json",
+ "client_name": "ai.card",
+ "client_uri": "https://xxxcard.syui.ai",
+ "logo_uri": "https://xxxcard.syui.ai/favicon.ico",
+ "tos_uri": "https://xxxcard.syui.ai/terms",
+ "policy_uri": "https://xxxcard.syui.ai/privacy",
+ "redirect_uris": [
+ "https://xxxcard.syui.ai/oauth/callback",
+ "https://xxxcard.syui.ai/"
+ ],
+ "response_types": [
+ "code"
+ ],
+ "grant_types": [
+ "authorization_code",
+ "refresh_token"
+ ],
+ "token_endpoint_auth_method": "none",
+ "scope": "atproto transition:generic",
+ "subject_type": "public",
+ "application_type": "web",
+ "dpop_bound_access_tokens": true
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/src/App.css b/aicard-web-oauth/src/App.css
new file mode 100644
index 0000000..a9ffffb
--- /dev/null
+++ b/aicard-web-oauth/src/App.css
@@ -0,0 +1,760 @@
+.app {
+ min-height: 100vh;
+ background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
+ color: #333333;
+}
+
+.app-header {
+ text-align: center;
+ padding: 40px 20px;
+ border-bottom: 1px solid #e9ecef;
+ position: relative;
+}
+
+.app-nav {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ padding: 20px;
+ background: rgba(0, 0, 0, 0.02);
+ border-bottom: 1px solid #e9ecef;
+ margin-bottom: 40px;
+}
+
+.nav-button {
+ padding: 12px 20px;
+ border: 1px solid #dee2e6;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.8);
+ color: #6c757d;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ backdrop-filter: blur(10px);
+}
+
+.nav-button:hover {
+ background: rgba(102, 126, 234, 0.1);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ color: #495057;
+}
+
+.nav-button.active {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: 1px solid #667eea;
+ box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
+}
+
+.nav-button.active:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
+}
+
+.app-header h1 {
+ font-size: 48px;
+ margin: 0;
+ background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.app-header p {
+ color: #6c757d;
+ margin-top: 10px;
+}
+
+.user-info {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ display: flex;
+ align-items: center;
+ gap: 15px;
+}
+
+.user-handle {
+ color: #495057;
+ font-weight: bold;
+ background: rgba(102, 126, 234, 0.1);
+ padding: 6px 12px;
+ border-radius: 20px;
+ border: 1px solid #dee2e6;
+}
+
+.login-button,
+.logout-button,
+.backup-button,
+.token-button {
+ padding: 8px 16px;
+ border: none;
+ border-radius: 8px;
+ font-size: 12px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ margin-left: 8px;
+}
+
+.login-button {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: 1px solid #667eea;
+}
+
+.backup-button {
+ background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
+ color: white;
+ border: 1px solid #28a745;
+}
+
+.token-button {
+ background: linear-gradient(135deg, #ffc107 0%, #fd7e14 100%);
+ color: white;
+ border: 1px solid #ffc107;
+}
+
+.logout-button {
+ background: rgba(108, 117, 125, 0.1);
+ color: #495057;
+ border: 1px solid #dee2e6;
+}
+
+.login-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+}
+
+.backup-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
+}
+
+.token-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(255, 193, 7, 0.4);
+}
+
+.logout-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ background: rgba(108, 117, 125, 0.2);
+}
+
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ font-size: 24px;
+ color: #667eea;
+}
+
+.app-main {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 40px 20px;
+}
+
+.gacha-section {
+ text-align: center;
+ margin-bottom: 60px;
+}
+
+.gacha-section h2 {
+ font-size: 32px;
+ margin-bottom: 30px;
+}
+
+.gacha-buttons {
+ display: flex;
+ gap: 20px;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.gacha-button {
+ padding: 20px 40px;
+ font-size: 18px;
+ font-weight: bold;
+ border: none;
+ border-radius: 12px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
+}
+
+.gacha-button:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
+}
+
+.gacha-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.gacha-button-premium {
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+ position: relative;
+ overflow: hidden;
+}
+
+.gacha-button-premium::before {
+ content: '';
+ position: absolute;
+ top: -50%;
+ left: -50%;
+ width: 200%;
+ height: 200%;
+ background: linear-gradient(
+ 45deg,
+ transparent 30%,
+ rgba(255, 255, 255, 0.2) 50%,
+ transparent 70%
+ );
+ animation: shimmer 3s infinite;
+}
+
+.collection-section h2 {
+ font-size: 32px;
+ text-align: center;
+ margin-bottom: 30px;
+}
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 30px;
+ justify-items: center;
+}
+
+.empty-message {
+ text-align: center;
+ color: #6c757d;
+ font-size: 18px;
+ margin-top: 40px;
+}
+
+.error {
+ color: #ff4757;
+ text-align: center;
+ margin-top: 20px;
+}
+
+@keyframes shimmer {
+ 0% { transform: translateX(-100%) rotate(45deg); }
+ 100% { transform: translateX(100%) rotate(45deg); }
+}
+
+/* Comment System Styles */
+.comment-section {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+.auth-section {
+ background: #f8f9fa;
+ border: 1px solid #e9ecef;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+ text-align: center;
+}
+
+.atproto-button {
+ background: #1185fe;
+ color: white;
+ border: none;
+ padding: 12px 24px;
+ border-radius: 6px;
+ font-size: 16px;
+ font-weight: bold;
+ cursor: pointer;
+ margin-bottom: 15px;
+ transition: all 0.3s ease;
+}
+
+.atproto-button:hover {
+ background: #0d6efd;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(17, 133, 254, 0.4);
+}
+
+.username-input-section {
+ margin: 15px 0;
+}
+
+.handle-input {
+ width: 300px;
+ max-width: 100%;
+ padding: 10px;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 14px;
+ text-align: center;
+}
+
+.auth-hint {
+ color: #6c757d;
+ font-size: 14px;
+ margin: 10px 0 0 0;
+}
+
+.user-section {
+ background: #e8f5e8;
+ border: 1px solid #4caf50;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.user-section .user-info {
+ position: static;
+ display: block;
+ margin-bottom: 20px;
+}
+
+.user-profile {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ margin-bottom: 15px;
+}
+
+.user-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid #4caf50;
+}
+
+.user-details h3 {
+ margin: 0 0 5px 0;
+ color: #333;
+ font-size: 18px;
+}
+
+.user-section .user-info h3 {
+ margin: 0 0 10px 0;
+ color: #333;
+}
+
+.user-section .user-handle {
+ background: rgba(76, 175, 80, 0.1);
+ color: #2e7d32;
+ border: 1px solid #4caf50;
+}
+
+.user-section .user-did {
+ font-family: monospace;
+ font-size: 0.8em;
+ color: #666;
+ background: #f1f3f4;
+ padding: 4px 8px;
+ border-radius: 4px;
+ margin-top: 5px;
+ word-break: break-all;
+}
+
+.comment-form {
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.comment-form h3 {
+ margin: 0 0 15px 0;
+ color: #333;
+}
+
+.comment-form textarea {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-family: inherit;
+ font-size: 14px;
+ resize: vertical;
+ box-sizing: border-box;
+ min-height: 100px;
+}
+
+.comment-form textarea:focus {
+ border-color: #1185fe;
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(17, 133, 254, 0.1);
+}
+
+.form-actions {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 10px;
+}
+
+.char-count {
+ color: #666;
+ font-size: 0.9em;
+}
+
+.post-button {
+ background: #28a745;
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: bold;
+ transition: all 0.3s ease;
+}
+
+.post-button:hover:not(:disabled) {
+ background: #218838;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
+}
+
+.post-button:disabled {
+ background: #6c757d;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: none;
+}
+
+.comments-list {
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ padding: 20px;
+}
+
+.comments-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+}
+
+.comments-header h3 {
+ margin: 0;
+ color: #333;
+}
+
+.comments-controls {
+ display: flex;
+ gap: 10px;
+}
+
+.comments-toggle-button {
+ background: #1185fe;
+ color: white;
+ border: none;
+ padding: 8px 16px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: bold;
+ transition: all 0.3s ease;
+}
+
+.comments-toggle-button:hover {
+ background: #0d6efd;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(17, 133, 254, 0.4);
+}
+
+.comment-item {
+ border: 1px solid #e9ecef;
+ border-radius: 6px;
+ padding: 15px;
+ margin-bottom: 15px;
+ background: #fff;
+}
+
+.comment-item:last-child {
+ margin-bottom: 0;
+}
+
+.comment-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+
+.comment-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 1px solid #ddd;
+}
+
+.comment-author-info {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1;
+}
+
+.comment-author {
+ font-weight: bold;
+ color: #333;
+ font-size: 0.95em;
+}
+
+.comment-handle {
+ color: #666;
+ font-size: 0.8em;
+}
+
+.comment-date {
+ color: #666;
+ font-size: 0.9em;
+ margin-left: auto;
+}
+
+.delete-button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 16px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ transition: all 0.3s ease;
+ margin-left: 8px;
+}
+
+.delete-button:hover {
+ background: rgba(220, 53, 69, 0.1);
+ transform: scale(1.1);
+}
+
+.comment-content {
+ line-height: 1.5;
+ color: #333;
+ margin-bottom: 10px;
+}
+
+.comment-meta {
+ padding: 8px;
+ background: #f1f3f4;
+ border-radius: 4px;
+ font-size: 0.8em;
+ color: #666;
+}
+
+.comment-meta small {
+ font-family: monospace;
+}
+
+.no-comments {
+ text-align: center;
+ color: #666;
+ font-style: italic;
+ padding: 40px;
+}
+
+.error {
+ background: #f8d7da;
+ color: #721c24;
+ border: 1px solid #f5c6cb;
+ border-radius: 4px;
+ padding: 10px;
+ margin-top: 10px;
+}
+
+/* Admin Section Styles */
+.admin-section {
+ background: #e3f2fd;
+ border: 1px solid #2196f3;
+ border-radius: 8px;
+ padding: 20px;
+ margin-top: 20px;
+}
+
+.admin-section h3 {
+ margin: 0 0 15px 0;
+ color: #1976d2;
+ font-size: 16px;
+}
+
+.user-list-form {
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ padding: 15px;
+}
+
+.user-list-form textarea {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-family: inherit;
+ font-size: 14px;
+ resize: vertical;
+ box-sizing: border-box;
+ min-height: 80px;
+}
+
+.user-list-form textarea:focus {
+ border-color: #2196f3;
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
+}
+
+.admin-hint {
+ color: #666;
+ font-size: 0.9em;
+ font-style: italic;
+}
+
+/* User List Records Styles */
+.user-list-records {
+ margin-top: 20px;
+}
+
+.user-list-records h4 {
+ margin: 0 0 15px 0;
+ color: #1976d2;
+ font-size: 14px;
+}
+
+.no-user-lists {
+ text-align: center;
+ color: #666;
+ font-style: italic;
+ padding: 20px;
+}
+
+.user-list-item {
+ border: 1px solid #e3f2fd;
+ border-radius: 6px;
+ padding: 12px;
+ margin-bottom: 10px;
+ background: #fff;
+}
+
+.user-list-item:last-child {
+ margin-bottom: 0;
+}
+
+.user-list-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.user-list-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.user-list-date {
+ color: #666;
+ font-size: 0.9em;
+ font-weight: 500;
+}
+
+.user-list-content {
+ margin-top: 8px;
+}
+
+.user-handles {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.user-handle-tag {
+ background: #e3f2fd;
+ color: #1976d2;
+ padding: 4px 8px;
+ border-radius: 12px;
+ font-size: 0.85em;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.pds-info {
+ color: #666;
+ font-size: 0.75em;
+ font-weight: normal;
+}
+
+.user-list-meta {
+ font-size: 0.8em;
+ color: #666;
+ background: #f8f9fa;
+ padding: 6px 8px;
+ border-radius: 4px;
+ line-height: 1.4;
+}
+
+.user-list-meta small {
+ font-family: monospace;
+}
+
+/* JSON Display Styles */
+.json-button {
+ background: #4caf50;
+ color: white;
+ border: none;
+ padding: 4px 8px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 500;
+ transition: all 0.3s ease;
+}
+
+.json-button:hover {
+ background: #45a049;
+ transform: scale(1.05);
+}
+
+.json-display {
+ margin-top: 12px;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.json-display h5 {
+ margin: 0;
+ padding: 8px 12px;
+ background: #f1f3f4;
+ border-bottom: 1px solid #ddd;
+ font-size: 0.9em;
+ color: #333;
+}
+
+.json-content {
+ margin: 0;
+ padding: 12px;
+ background: #f8f9fa;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 0.8em;
+ line-height: 1.4;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #333;
+ max-height: 400px;
+ overflow-y: auto;
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/src/App.tsx b/aicard-web-oauth/src/App.tsx
new file mode 100644
index 0000000..3d105c2
--- /dev/null
+++ b/aicard-web-oauth/src/App.tsx
@@ -0,0 +1,946 @@
+import React, { useState, useEffect } from 'react';
+import { OAuthCallback } from './components/OAuthCallback';
+import { authService, User } from './services/auth';
+import { atprotoOAuthService } from './services/atproto-oauth';
+import './App.css';
+
+function App() {
+ console.log('APP COMPONENT LOADED - Console working!');
+ console.log('Current timestamp:', new Date().toISOString());
+
+ // Immediately log URL information on every page load
+ console.log('IMMEDIATE URL CHECK:');
+ console.log('- href:', window.location.href);
+ console.log('- pathname:', window.location.pathname);
+ console.log('- search:', window.location.search);
+ console.log('- hash:', window.location.hash);
+
+ // Also show URL info via alert if it contains OAuth parameters
+ if (window.location.search.includes('code=') || window.location.search.includes('state=')) {
+ const urlInfo = `OAuth callback detected!\n\nURL: ${window.location.href}\nSearch: ${window.location.search}`;
+ alert(urlInfo);
+ console.log('OAuth callback URL detected!');
+ } else {
+ // Check if we have stored OAuth info from previous steps
+ const preOAuthUrl = sessionStorage.getItem('pre_oauth_url');
+ const storedState = sessionStorage.getItem('oauth_state');
+ const storedCodeVerifier = sessionStorage.getItem('oauth_code_verifier');
+
+ console.log('=== OAUTH SESSION STORAGE CHECK ===');
+ console.log('Pre-OAuth URL:', preOAuthUrl);
+ console.log('Stored state:', storedState);
+ console.log('Stored code verifier:', storedCodeVerifier ? 'Present' : 'Missing');
+ console.log('=== END SESSION STORAGE CHECK ===');
+ }
+
+ const [user, setUser] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [comments, setComments] = useState([]);
+ const [commentText, setCommentText] = useState('');
+ const [isPosting, setIsPosting] = useState(false);
+ const [error, setError] = useState(null);
+ const [handleInput, setHandleInput] = useState('');
+ const [userListInput, setUserListInput] = useState('');
+ const [isPostingUserList, setIsPostingUserList] = useState(false);
+ const [userListRecords, setUserListRecords] = useState([]);
+ const [showJsonFor, setShowJsonFor] = useState(null);
+
+ useEffect(() => {
+ // Setup Jetstream WebSocket for real-time comments (optional)
+ const setupJetstream = () => {
+ try {
+ const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe');
+
+ ws.onopen = () => {
+ console.log('Jetstream connected');
+ ws.send(JSON.stringify({
+ wantedCollections: ['ai.syui.log']
+ }));
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ if (data.collection === 'ai.syui.log' && data.commit?.operation === 'create') {
+ console.log('New comment detected via Jetstream:', data);
+ // Optionally reload comments
+ // loadAllComments(window.location.href);
+ }
+ } catch (err) {
+ console.warn('Failed to parse Jetstream message:', err);
+ }
+ };
+
+ ws.onerror = (err) => {
+ console.warn('Jetstream error:', err);
+ };
+
+ return ws;
+ } catch (err) {
+ console.warn('Failed to setup Jetstream:', err);
+ return null;
+ }
+ };
+
+ // Jetstream + Cache example
+ const jetstream = setupJetstream();
+
+ // キャッシュからコメント読み込み
+ const loadCachedComments = () => {
+ const cached = localStorage.getItem('cached_comments_' + window.location.pathname);
+ if (cached) {
+ const { comments: cachedComments, timestamp } = JSON.parse(cached);
+ // 5分以内のキャッシュなら使用
+ if (Date.now() - timestamp < 5 * 60 * 1000) {
+ setComments(cachedComments);
+ return true;
+ }
+ }
+ return false;
+ };
+
+ // キャッシュがなければ、ATProtoから取得
+ if (!loadCachedComments()) {
+ loadAllComments(window.location.href);
+ }
+
+ // Handle popstate events for mock OAuth flow
+ const handlePopState = () => {
+ const urlParams = new URLSearchParams(window.location.search);
+ const isOAuthCallback = urlParams.has('code') && urlParams.has('state');
+
+ if (isOAuthCallback) {
+ // Force re-render to handle OAuth callback
+ window.location.reload();
+ }
+ };
+
+ window.addEventListener('popstate', handlePopState);
+
+ // Check if this is an OAuth callback
+ const urlParams = new URLSearchParams(window.location.search);
+ const isOAuthCallback = urlParams.has('code') && urlParams.has('state');
+
+ if (isOAuthCallback) {
+ return; // Let OAuthCallback component handle this
+ }
+
+ // Check existing sessions
+ const checkAuth = async () => {
+ // First check OAuth session using official BrowserOAuthClient
+ console.log('Checking OAuth session...');
+ const oauthResult = await atprotoOAuthService.checkSession();
+ console.log('OAuth checkSession result:', oauthResult);
+
+ if (oauthResult) {
+ console.log('OAuth session found:', oauthResult);
+ // Ensure handle is not DID
+ const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle;
+
+ // Get user profile including avatar
+ const userProfile = await getUserProfile(oauthResult.did, handle);
+ setUser(userProfile);
+
+ // Load all comments for display (this will be the default view)
+ loadAllComments(window.location.href);
+
+ // Load user list records if admin
+ if (userProfile.did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
+ loadUserListRecords();
+ }
+
+ setIsLoading(false);
+ return;
+ } else {
+ console.log('No OAuth session found');
+ }
+
+ // Fallback to legacy auth
+ const verifiedUser = await authService.verify();
+ if (verifiedUser) {
+ setUser(verifiedUser);
+
+ // Load all comments for display (this will be the default view)
+ loadAllComments(window.location.href);
+
+ // Load user list records if admin
+ if (verifiedUser.did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
+ loadUserListRecords();
+ }
+ }
+ setIsLoading(false);
+ };
+
+ checkAuth();
+
+ return () => {
+ window.removeEventListener('popstate', handlePopState);
+ };
+ }, []);
+
+ const getUserProfile = async (did: string, handle: string): Promise => {
+ try {
+ const agent = atprotoOAuthService.getAgent();
+ if (agent) {
+ const profile = await agent.getProfile({ actor: handle });
+ return {
+ did: did,
+ handle: handle,
+ avatar: profile.data.avatar,
+ displayName: profile.data.displayName || handle
+ };
+ }
+ } catch (error) {
+ console.error('Failed to get user profile:', error);
+ }
+
+ // Fallback to basic user info
+ return {
+ did: did,
+ handle: handle,
+ avatar: generatePlaceholderAvatar(handle),
+ displayName: handle
+ };
+ };
+
+ const generatePlaceholderAvatar = (handle: string): string => {
+ const initial = handle ? handle.charAt(0).toUpperCase() : 'U';
+ return `https://via.placeholder.com/48x48/1185fe/ffffff?text=${initial}`;
+ };
+
+ const loadUserComments = async (did: string) => {
+ try {
+ console.log('Loading comments for DID:', did);
+ const agent = atprotoOAuthService.getAgent();
+ if (!agent) {
+ console.log('No agent available');
+ return;
+ }
+
+ // Get comments from current user
+ const response = await agent.api.com.atproto.repo.listRecords({
+ repo: did,
+ collection: 'ai.syui.log',
+ limit: 100,
+ });
+
+ console.log('User comments loaded:', response.data);
+ const userComments = response.data.records || [];
+
+ // Enhance comments with profile information if missing
+ const enhancedComments = await Promise.all(
+ userComments.map(async (record) => {
+ if (!record.value.author?.avatar && record.value.author?.handle) {
+ try {
+ const profile = await agent.getProfile({ actor: record.value.author.handle });
+ return {
+ ...record,
+ value: {
+ ...record.value,
+ author: {
+ ...record.value.author,
+ avatar: profile.data.avatar,
+ displayName: profile.data.displayName || record.value.author.handle,
+ }
+ }
+ };
+ } catch (err) {
+ console.warn('Failed to enhance comment with profile:', err);
+ return record;
+ }
+ }
+ return record;
+ })
+ );
+
+ setComments(enhancedComments);
+ } catch (err) {
+ console.error('Failed to load comments:', err);
+ setComments([]);
+ }
+ };
+
+ // JSONからユーザーリストを取得
+ const loadUsersFromRecord = async () => {
+ try {
+ // 管理者のユーザーリストを取得 (ai.syui.log.user collection)
+ const adminDid = 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; // syui.ai
+ const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=ai.syui.log.user&limit=100`);
+
+ if (!response.ok) {
+ console.warn('Failed to fetch user list from admin, using default users');
+ return getDefaultUsers();
+ }
+
+ const data = await response.json();
+ const userRecords = data.records || [];
+
+ if (userRecords.length === 0) {
+ return getDefaultUsers();
+ }
+
+ // レコードからユーザーリストを構築し、プレースホルダーDIDを実際のDIDに解決
+ const allUsers = [];
+ for (const record of userRecords) {
+ if (record.value.users) {
+ // プレースホルダーDIDを実際のDIDに解決
+ const resolvedUsers = await Promise.all(
+ record.value.users.map(async (user) => {
+ if (user.did && user.did.includes('-placeholder')) {
+ console.log(`Resolving placeholder DID for ${user.handle}`);
+ try {
+ const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(user.handle)}`);
+ if (profileResponse.ok) {
+ const profileData = await profileResponse.json();
+ if (profileData.did) {
+ console.log(`Resolved ${user.handle}: ${user.did} -> ${profileData.did}`);
+ return {
+ ...user,
+ did: profileData.did
+ };
+ }
+ }
+ } catch (err) {
+ console.warn(`Failed to resolve DID for ${user.handle}:`, err);
+ }
+ }
+ return user;
+ })
+ );
+ allUsers.push(...resolvedUsers);
+ }
+ }
+
+ console.log('Loaded and resolved users from admin records:', allUsers);
+ return allUsers;
+ } catch (err) {
+ console.warn('Failed to load users from records, using defaults:', err);
+ return getDefaultUsers();
+ }
+ };
+
+ // ユーザーリスト一覧を読み込み
+ const loadUserListRecords = async () => {
+ try {
+ console.log('Loading user list records...');
+ const adminDid = 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; // syui.ai
+ const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=ai.syui.log.user&limit=100`);
+
+ if (!response.ok) {
+ console.warn('Failed to fetch user list records');
+ setUserListRecords([]);
+ return;
+ }
+
+ const data = await response.json();
+ const records = data.records || [];
+
+ // 新しい順にソート
+ const sortedRecords = records.sort((a, b) =>
+ new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
+ );
+
+ console.log(`Loaded ${sortedRecords.length} user list records`);
+ setUserListRecords(sortedRecords);
+ } catch (err) {
+ console.error('Failed to load user list records:', err);
+ setUserListRecords([]);
+ }
+ };
+
+ const getDefaultUsers = () => {
+ return [
+ // bsky.social - 実際のDIDを使用
+ { did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn', handle: 'syui.ai', pds: 'https://bsky.social' },
+ // 他のユーザーは実際のDIDが不明なので、実在するユーザーのみ含める
+ ];
+ };
+
+ // 新しい関数: 全ユーザーからコメントを収集
+ const loadAllComments = async (pageUrl?: string) => {
+ try {
+ console.log('Loading comments from all users...');
+
+ // ユーザーリストを動的に取得
+ const knownUsers = await loadUsersFromRecord();
+
+ const allComments = [];
+
+ // 各ユーザーからコメントを収集
+ for (const user of knownUsers) {
+ try {
+ console.log(`Fetching comments from user: ${user.handle} (${user.did}) at ${user.pds}`);
+
+ // Public API使用(認証不要)
+ const response = await fetch(`${user.pds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(user.did)}&collection=ai.syui.log&limit=100`);
+
+ if (!response.ok) {
+ console.warn(`Failed to fetch from ${user.handle} (${response.status}): ${response.statusText}`);
+ continue;
+ }
+
+ const data = await response.json();
+ const userComments = data.records || [];
+ console.log(`Found ${userComments.length} comments from ${user.handle}`);
+
+ // ページURLでフィルタリング(指定された場合)
+ const filteredComments = pageUrl
+ ? userComments.filter(record => record.value.url === pageUrl)
+ : userComments;
+
+ console.log(`After URL filtering: ${filteredComments.length} comments from ${user.handle}`);
+ allComments.push(...filteredComments);
+ } catch (err) {
+ console.warn(`Failed to load comments from ${user.handle}:`, err);
+ }
+ }
+
+ // 時間順にソート(新しい順)
+ const sortedComments = allComments.sort((a, b) =>
+ new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
+ );
+
+ // プロフィール情報で拡張(認証なしでも取得可能)
+ const enhancedComments = await Promise.all(
+ sortedComments.map(async (record) => {
+ if (!record.value.author?.avatar && record.value.author?.handle) {
+ try {
+ // Public API でプロフィール取得
+ const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.handle)}`);
+
+ if (profileResponse.ok) {
+ const profileData = await profileResponse.json();
+ return {
+ ...record,
+ value: {
+ ...record.value,
+ author: {
+ ...record.value.author,
+ avatar: profileData.avatar,
+ displayName: profileData.displayName || record.value.author.handle,
+ }
+ }
+ };
+ }
+ } catch (err) {
+ console.warn('Failed to enhance comment with profile:', err);
+ }
+ }
+ return record;
+ })
+ );
+
+ console.log(`Loaded ${enhancedComments.length} comments from all users`);
+
+ // デバッグ情報を追加
+ console.log('Final enhanced comments:', enhancedComments);
+ console.log('Known users used:', knownUsers);
+
+ setComments(enhancedComments);
+
+ // キャッシュに保存(5分間有効)
+ if (pageUrl) {
+ const cacheKey = 'cached_comments_' + new URL(pageUrl).pathname;
+ const cacheData = {
+ comments: enhancedComments,
+ timestamp: Date.now()
+ };
+ localStorage.setItem(cacheKey, JSON.stringify(cacheData));
+ }
+ } catch (err) {
+ console.error('Failed to load all comments:', err);
+ setComments([]);
+ }
+ };
+
+
+ const handlePostComment = async () => {
+ if (!user || !commentText.trim()) {
+ return;
+ }
+
+ setIsPosting(true);
+ setError(null);
+
+ try {
+ const agent = atprotoOAuthService.getAgent();
+ if (!agent) {
+ throw new Error('No agent available');
+ }
+
+ // Create comment record with ISO datetime rkey
+ const now = new Date();
+ const rkey = now.toISOString().replace(/[:.]/g, '-'); // Replace : and . with - for valid rkey
+
+ const record = {
+ $type: 'ai.syui.log',
+ text: commentText,
+ url: window.location.href,
+ createdAt: now.toISOString(),
+ author: {
+ did: user.did,
+ handle: user.handle,
+ avatar: user.avatar,
+ displayName: user.displayName || user.handle,
+ },
+ };
+
+ // Post to ATProto with rkey
+ const response = await agent.api.com.atproto.repo.putRecord({
+ repo: user.did,
+ collection: 'ai.syui.log',
+ rkey: rkey,
+ record: record,
+ });
+
+ console.log('Comment posted:', response);
+
+ // Clear form and reload all comments
+ setCommentText('');
+ await loadAllComments(window.location.href);
+ } catch (err: any) {
+ console.error('Failed to post comment:', err);
+ setError('コメントの投稿に失敗しました: ' + err.message);
+ } finally {
+ setIsPosting(false);
+ }
+ };
+
+ const handleDeleteComment = async (uri: string) => {
+ if (!user) {
+ alert('ログインが必要です');
+ return;
+ }
+
+ if (!confirm('このコメントを削除しますか?')) {
+ return;
+ }
+
+ try {
+ const agent = atprotoOAuthService.getAgent();
+ if (!agent) {
+ throw new Error('No agent available');
+ }
+
+ // Extract rkey from URI: at://did:plc:xxx/ai.syui.log/rkey
+ const uriParts = uri.split('/');
+ const rkey = uriParts[uriParts.length - 1];
+
+ console.log('Deleting comment with rkey:', rkey);
+
+ // Delete the record
+ await agent.api.com.atproto.repo.deleteRecord({
+ repo: user.did,
+ collection: 'ai.syui.log',
+ rkey: rkey,
+ });
+
+ console.log('Comment deleted successfully');
+
+ // Reload all comments to reflect the deletion
+ await loadAllComments(window.location.href);
+
+ } catch (err: any) {
+ console.error('Failed to delete comment:', err);
+ alert('コメントの削除に失敗しました: ' + err.message);
+ }
+ };
+
+ const handleLogout = async () => {
+ // Logout from both services
+ await authService.logout();
+ atprotoOAuthService.logout();
+ setUser(null);
+ setComments([]);
+ };
+
+ // 管理者チェック
+ const isAdmin = (user: User | null): boolean => {
+ return user?.did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; // syui.ai
+ };
+
+ // ユーザーリスト投稿
+ const handlePostUserList = async () => {
+ if (!user || !userListInput.trim()) {
+ return;
+ }
+
+ if (!isAdmin(user)) {
+ alert('管理者のみがユーザーリストを更新できます');
+ return;
+ }
+
+ setIsPostingUserList(true);
+ setError(null);
+
+ try {
+ const agent = atprotoOAuthService.getAgent();
+ if (!agent) {
+ throw new Error('No agent available');
+ }
+
+ // ユーザーリストをパース
+ const userHandles = userListInput
+ .split(',')
+ .map(handle => handle.trim())
+ .filter(handle => handle.length > 0);
+
+ // ユーザーリストを各PDS用に分類し、実際のDIDを解決
+ const users = await Promise.all(userHandles.map(async (handle) => {
+ const pds = handle.endsWith('.syu.is') ? 'https://syu.is' : 'https://bsky.social';
+
+ // 実際のDIDを解決
+ let resolvedDid = `did:plc:${handle.replace(/\./g, '-')}-placeholder`; // フォールバック
+
+ try {
+ // Public APIでプロフィールを取得してDIDを解決
+ const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
+ if (profileResponse.ok) {
+ const profileData = await profileResponse.json();
+ if (profileData.did) {
+ resolvedDid = profileData.did;
+ console.log(`Resolved ${handle} -> ${resolvedDid}`);
+ }
+ }
+ } catch (err) {
+ console.warn(`Failed to resolve DID for ${handle}:`, err);
+ }
+
+ return {
+ handle: handle,
+ pds: pds,
+ did: resolvedDid
+ };
+ }));
+
+ // Create user list record with ISO datetime rkey
+ const now = new Date();
+ const rkey = now.toISOString().replace(/[:.]/g, '-');
+
+ const record = {
+ $type: 'ai.syui.log.user',
+ users: users,
+ createdAt: now.toISOString(),
+ updatedBy: {
+ did: user.did,
+ handle: user.handle,
+ },
+ };
+
+ // Post to ATProto with rkey
+ const response = await agent.api.com.atproto.repo.putRecord({
+ repo: user.did,
+ collection: 'ai.syui.log.user',
+ rkey: rkey,
+ record: record,
+ });
+
+ console.log('User list posted:', response);
+
+ // Clear form and reload user list records
+ setUserListInput('');
+ loadUserListRecords();
+ alert('ユーザーリストが更新されました');
+ } catch (err: any) {
+ console.error('Failed to post user list:', err);
+ setError('ユーザーリストの投稿に失敗しました: ' + err.message);
+ } finally {
+ setIsPostingUserList(false);
+ }
+ };
+
+ // ユーザーリスト削除
+ const handleDeleteUserList = async (uri: string) => {
+ if (!user || !isAdmin(user)) {
+ alert('管理者のみがユーザーリストを削除できます');
+ return;
+ }
+
+ if (!confirm('このユーザーリストを削除しますか?')) {
+ return;
+ }
+
+ try {
+ const agent = atprotoOAuthService.getAgent();
+ if (!agent) {
+ throw new Error('No agent available');
+ }
+
+ // Extract rkey from URI
+ const uriParts = uri.split('/');
+ const rkey = uriParts[uriParts.length - 1];
+
+ console.log('Deleting user list with rkey:', rkey);
+
+ // Delete the record
+ await agent.api.com.atproto.repo.deleteRecord({
+ repo: user.did,
+ collection: 'ai.syui.log.user',
+ rkey: rkey,
+ });
+
+ console.log('User list deleted successfully');
+ loadUserListRecords();
+ alert('ユーザーリストが削除されました');
+
+ } catch (err: any) {
+ console.error('Failed to delete user list:', err);
+ alert('ユーザーリストの削除に失敗しました: ' + err.message);
+ }
+ };
+
+ // JSON表示のトグル
+ const toggleJsonDisplay = (uri: string) => {
+ if (showJsonFor === uri) {
+ setShowJsonFor(null);
+ } else {
+ setShowJsonFor(uri);
+ }
+ };
+
+ // OAuth callback is now handled by React Router in main.tsx
+ console.log('=== APP.TSX URL CHECK ===');
+ console.log('Full URL:', window.location.href);
+ console.log('Pathname:', window.location.pathname);
+ console.log('Search params:', window.location.search);
+ console.log('=== END URL CHECK ===');
+
+
+ return (
+
+
+
+
+ {/* Authentication Section */}
+ {!user ? (
+
+
+
+ setHandleInput(e.target.value)}
+ />
+
+
+ ) : (
+
+
+
+

+
+
{user.displayName || user.handle}
+
@{user.handle}
+
DID: {user.did}
+
+
+
+
+
+ {/* Admin Section - User Management */}
+ {isAdmin(user) && (
+
+
管理者機能 - ユーザーリスト管理
+
+ {/* User List Form */}
+
+
+ {/* User List Records */}
+
+
ユーザーリスト一覧 ({userListRecords.length}件)
+ {userListRecords.length === 0 ? (
+
ユーザーリストが見つかりません
+ ) : (
+ userListRecords.map((record, index) => (
+
+
+
+ {new Date(record.value.createdAt).toLocaleString()}
+
+
+
+
+
+
+
+
+ {record.value.users && record.value.users.map((user, userIndex) => (
+
+ {user.handle}
+ ({new URL(user.pds).hostname})
+
+ ))}
+
+
+ URI: {record.uri}
+
+ Updated by: {record.value.updatedBy?.handle || 'unknown'}
+
+
+ {/* JSON Display */}
+ {showJsonFor === record.uri && (
+
+
JSON Record:
+
+ {JSON.stringify(record, null, 2)}
+
+
+ )}
+
+
+ ))
+ )}
+
+
+ )}
+
+
+ )}
+
+ {/* Comments List */}
+
+
+
Comments
+
+
+
+
+
+ {comments.length === 0 ? (
+
No comments yet
+ ) : (
+ comments.map((record, index) => (
+
+
+

+
+
+ {record.value.author?.displayName || record.value.author?.handle || 'unknown'}
+
+ @{record.value.author?.handle || 'unknown'}
+
+
+ {new Date(record.value.createdAt).toLocaleString()}
+
+ {/* Show delete button only for current user's comments */}
+ {user && record.value.author?.did === user.did && (
+
+ )}
+
+
+ {record.value.text}
+
+
+ URI: {record.uri}
+
+
+ ))
+ )}
+
+
+ {/* Comment Form - Outside user section, after comments list */}
+ {user && (
+
+ )}
+
+
+
+
+ );
+}
+
+export default App;
\ No newline at end of file
diff --git a/aicard-web-oauth/src/components/Card.tsx b/aicard-web-oauth/src/components/Card.tsx
new file mode 100644
index 0000000..4d6dc2e
--- /dev/null
+++ b/aicard-web-oauth/src/components/Card.tsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { motion } from 'framer-motion';
+import { Card as CardType, CardRarity } from '../types/card';
+import '../styles/Card.css';
+
+interface CardProps {
+ card: CardType;
+ isRevealing?: boolean;
+ detailed?: boolean;
+}
+
+const CARD_INFO: Record = {
+ 0: { name: "アイ", color: "#fff700" },
+ 1: { name: "夢幻", color: "#b19cd9" },
+ 2: { name: "光彩", color: "#ffd700" },
+ 3: { name: "中性子", color: "#cacfd2" },
+ 4: { name: "太陽", color: "#ff6b35" },
+ 5: { name: "夜空", color: "#1a1a2e" },
+ 6: { name: "雪", color: "#e3f2fd" },
+ 7: { name: "雷", color: "#ffd93d" },
+ 8: { name: "超究", color: "#6c5ce7" },
+ 9: { name: "剣", color: "#a8e6cf" },
+ 10: { name: "破壊", color: "#ff4757" },
+ 11: { name: "地球", color: "#4834d4" },
+ 12: { name: "天の川", color: "#9c88ff" },
+ 13: { name: "創造", color: "#00d2d3" },
+ 14: { name: "超新星", color: "#ff9ff3" },
+ 15: { name: "世界", color: "#54a0ff" },
+};
+
+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) {
+ case CardRarity.UNIQUE:
+ return 'card-unique';
+ case CardRarity.KIRA:
+ return 'card-kira';
+ case CardRarity.SUPER_RARE:
+ return 'card-super-rare';
+ case CardRarity.RARE:
+ return 'card-rare';
+ default:
+ return 'card-normal';
+ }
+ };
+
+ if (!detailed) {
+ // Simple view - only image and frame
+ return (
+
+
+

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

{
+ (e.target as HTMLImageElement).style.display = 'none';
+ }}
+ />
+
+
+
+
{cardInfo.name}
+ {card.is_unique && (
+
UNIQUE
+ )}
+
+
+ {card.skill && (
+
+ )}
+
+
+ {card.status.toUpperCase()}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/aicard-web-oauth/src/components/CardBox.tsx b/aicard-web-oauth/src/components/CardBox.tsx
new file mode 100644
index 0000000..f87df79
--- /dev/null
+++ b/aicard-web-oauth/src/components/CardBox.tsx
@@ -0,0 +1,171 @@
+import React, { useState, useEffect } from 'react';
+import { atprotoOAuthService } from '../services/atproto-oauth';
+import { Card } from './Card';
+import '../styles/CardBox.css';
+
+interface CardBoxProps {
+ userDid: string;
+}
+
+export const CardBox: React.FC = ({ userDid }) => {
+ const [boxData, setBoxData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [showJson, setShowJson] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ useEffect(() => {
+ loadBoxData();
+ }, [userDid]);
+
+ const loadBoxData = async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const data = await atprotoOAuthService.getCardsFromBox();
+ setBoxData(data);
+ } catch (err) {
+ console.error('カードボックス読み込みエラー:', err);
+ setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSaveToBox = async () => {
+ // 現在のカードデータを取得してボックスに保存
+ // この部分は親コンポーネントから渡すか、APIから取得する必要があります
+ 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 (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
エラー: {error}
+
+
+ );
+ }
+
+ const records = boxData?.records || [];
+ const selfRecord = records.find((record: any) => record.uri.includes('/self'));
+ const cards = selfRecord?.value?.cards || [];
+
+ return (
+
+
+
📦 atproto カードボックス
+
+
+
+ {cards.length > 0 && (
+
+ )}
+
+
+
+
+
+ 📍 URI:
+ at://did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.card.box/self
+
+
+
+ {showJson && (
+
+
Raw JSON データ:
+
+ {JSON.stringify(boxData, null, 2)}
+
+
+ )}
+
+
+
+ 総カード数: {cards.length}枚
+ {selfRecord?.value?.updated_at && (
+ <>
+
+ 最終更新: {new Date(selfRecord.value.updated_at).toLocaleString()}
+ >
+ )}
+
+
+
+ {cards.length > 0 ? (
+ <>
+
+ {cards.map((card: any, index: number) => (
+
+
+
+ ID: {card.id} | CP: {card.cp}
+
+
+ ))}
+
+ >
+ ) : (
+
+
カードボックスにカードがありません
+
カードを引いてからバックアップボタンを押してください
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/aicard-web-oauth/src/components/CardList.tsx b/aicard-web-oauth/src/components/CardList.tsx
new file mode 100644
index 0000000..89138bd
--- /dev/null
+++ b/aicard-web-oauth/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/aicard-web-oauth/src/components/CollectionAnalysis.tsx b/aicard-web-oauth/src/components/CollectionAnalysis.tsx
new file mode 100644
index 0000000..dea8636
--- /dev/null
+++ b/aicard-web-oauth/src/components/CollectionAnalysis.tsx
@@ -0,0 +1,133 @@
+import React, { useState, useEffect } from 'react';
+import { aiCardApi } from '../services/api';
+import '../styles/CollectionAnalysis.css';
+
+interface AnalysisData {
+ total_cards: number;
+ unique_cards: number;
+ rarity_distribution: Record;
+ collection_score: number;
+ recommendations: string[];
+}
+
+interface CollectionAnalysisProps {
+ userDid: string;
+}
+
+export const CollectionAnalysis: React.FC = ({ userDid }) => {
+ const [analysis, setAnalysis] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadAnalysis = async () => {
+ if (!userDid) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const result = await aiCardApi.analyzeCollection(userDid);
+ setAnalysis(result);
+ } catch (err) {
+ console.error('Collection analysis failed:', err);
+ setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadAnalysis();
+ }, [userDid]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!analysis) {
+ return (
+
+
+
分析データがありません
+
+
+
+ );
+ }
+
+ return (
+
+
🧠 AI コレクション分析
+
+
+
+
{analysis.total_cards}
+
総カード数
+
+
+
{analysis.unique_cards}
+
ユニークカード
+
+
+
{analysis.collection_score}
+
コレクションスコア
+
+
+
+
+
レアリティ分布
+
+ {Object.entries(analysis.rarity_distribution).map(([rarity, count]) => (
+
+ ))}
+
+
+
+ {analysis.recommendations && analysis.recommendations.length > 0 && (
+
+
🎯 AI推奨
+
+ {analysis.recommendations.map((rec, index) => (
+ - {rec}
+ ))}
+
+
+ )}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/aicard-web-oauth/src/components/GachaAnimation.tsx b/aicard-web-oauth/src/components/GachaAnimation.tsx
new file mode 100644
index 0000000..b1d6830
--- /dev/null
+++ b/aicard-web-oauth/src/components/GachaAnimation.tsx
@@ -0,0 +1,130 @@
+import React, { useState, useEffect } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { Card } from './Card';
+import { Card as CardType } from '../types/card';
+import { atprotoOAuthService } from '../services/atproto-oauth';
+import '../styles/GachaAnimation.css';
+
+interface GachaAnimationProps {
+ card: CardType;
+ animationType: string;
+ onComplete: () => void;
+}
+
+export const GachaAnimation: React.FC = ({
+ card,
+ animationType,
+ onComplete
+}) => {
+ const [phase, setPhase] = useState<'opening' | 'revealing' | 'complete'>('opening');
+ const [showCard, setShowCard] = useState(false);
+ const [isSharing, setIsSharing] = useState(false);
+
+ useEffect(() => {
+ const timer1 = setTimeout(() => setPhase('revealing'), 1500);
+ const timer2 = setTimeout(() => {
+ setPhase('complete');
+ setShowCard(true);
+ }, 3000);
+
+ return () => {
+ clearTimeout(timer1);
+ clearTimeout(timer2);
+ };
+ }, [onComplete]);
+
+ const handleCardClick = () => {
+ if (showCard) {
+ onComplete();
+ }
+ };
+
+ const handleSaveToCollection = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (isSharing) return;
+
+ setIsSharing(true);
+ try {
+ await atprotoOAuthService.saveCardToCollection(card);
+ alert('カードデータをatprotoコレクションに保存しました!');
+ } catch (error) {
+ console.error('保存エラー:', error);
+ alert('保存に失敗しました。認証が必要かもしれません。');
+ } finally {
+ setIsSharing(false);
+ }
+ };
+
+ const getEffectClass = () => {
+ switch (animationType) {
+ case 'unique':
+ return 'effect-unique';
+ case 'kira':
+ return 'effect-kira';
+ case 'rare':
+ return 'effect-rare';
+ default:
+ return 'effect-normal';
+ }
+ };
+
+ return (
+
+
+ {phase === 'opening' && (
+
+
+
+ )}
+
+ {phase === 'revealing' && (
+
+
+
+ )}
+
+ {phase === 'complete' && showCard && (
+
+
+
+
+
クリックして閉じる
+
+
+ )}
+
+
+ {animationType === 'unique' && (
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/aicard-web-oauth/src/components/GachaStats.tsx b/aicard-web-oauth/src/components/GachaStats.tsx
new file mode 100644
index 0000000..86fb4e8
--- /dev/null
+++ b/aicard-web-oauth/src/components/GachaStats.tsx
@@ -0,0 +1,144 @@
+import React, { useState, useEffect } from 'react';
+import { cardApi, aiCardApi } from '../services/api';
+import '../styles/GachaStats.css';
+
+interface GachaStatsData {
+ total_draws: number;
+ cards_by_rarity: Record;
+ success_rates: Record;
+ recent_activity: Array<{
+ timestamp: string;
+ user_did: string;
+ card_name: string;
+ rarity: string;
+ }>;
+}
+
+export const GachaStats: React.FC = () => {
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [useAI, setUseAI] = useState(true);
+
+ const loadStats = async () => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ let result;
+ if (useAI) {
+ try {
+ result = await aiCardApi.getEnhancedStats();
+ } catch (aiError) {
+ console.warn('AI統計が利用できません、基本統計に切り替えます:', aiError);
+ setUseAI(false);
+ result = await cardApi.getGachaStats();
+ }
+ } else {
+ result = await cardApi.getGachaStats();
+ }
+ setStats(result);
+ } catch (err) {
+ console.error('Gacha stats failed:', err);
+ setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ loadStats();
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!stats) {
+ return (
+
+
+
統計データがありません
+
+
+
+ );
+ }
+
+ return (
+
+
📊 ガチャ統計
+
+
+
+
{stats.total_draws}
+
総ガチャ実行数
+
+
+
+
+
レアリティ別出現数
+
+ {Object.entries(stats.cards_by_rarity).map(([rarity, count]) => (
+
+
{count}
+
{rarity}
+ {stats.success_rates[rarity] && (
+
+ {(stats.success_rates[rarity] * 100).toFixed(1)}%
+
+ )}
+
+ ))}
+
+
+
+ {stats.recent_activity && stats.recent_activity.length > 0 && (
+
+
最近の活動
+
+ {stats.recent_activity.slice(0, 5).map((activity, index) => (
+
+
+ {new Date(activity.timestamp).toLocaleString()}
+
+
+
+ {activity.rarity}
+
+ {activity.card_name}
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/aicard-web-oauth/src/components/Login.tsx b/aicard-web-oauth/src/components/Login.tsx
new file mode 100644
index 0000000..082adf5
--- /dev/null
+++ b/aicard-web-oauth/src/components/Login.tsx
@@ -0,0 +1,203 @@
+import React, { useState } from 'react';
+import { motion } from 'framer-motion';
+import { authService } from '../services/auth';
+import { atprotoOAuthService } from '../services/atproto-oauth';
+import '../styles/Login.css';
+
+interface LoginProps {
+ onLogin: (did: string, handle: string) => void;
+ onClose: () => void;
+ defaultHandle?: string;
+}
+
+export const Login: React.FC = ({ onLogin, onClose, defaultHandle }) => {
+ const [loginMode, setLoginMode] = useState<'oauth' | 'legacy'>('oauth');
+ const [identifier, setIdentifier] = useState(defaultHandle || '');
+ const [password, setPassword] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleOAuthLogin = async () => {
+ setError(null);
+ setIsLoading(true);
+
+ try {
+ // Prompt for handle if not provided
+ const handle = identifier.trim() || undefined;
+ await atprotoOAuthService.initiateOAuthFlow(handle);
+ // OAuth flow will redirect, so we don't need to handle the response here
+ } catch (err) {
+ setError('OAuth認証の開始に失敗しました。');
+ setIsLoading(false);
+ }
+ };
+
+ const handleLegacyLogin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setIsLoading(true);
+
+ try {
+ const response = await authService.login(identifier, password);
+ onLogin(response.did, response.handle);
+ } catch (err) {
+ setError('ログインに失敗しました。認証情報を確認してください。');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ e.stopPropagation()}
+ >
+ atprotoログイン
+
+
+
+
+
+
+ {loginMode === 'oauth' ? (
+
+
+
🔐 OAuth 2.1 認証
+
+ より安全で標準準拠の認証方式です。
+ ブラウザが一時的にatproto認証サーバーにリダイレクトされます。
+
+ {(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost') && (
+
+ 🛠️ 開発環境: モック認証を使用します(実際のBlueskyにはアクセスしません)
+
+ )}
+
+
+
+
+ setIdentifier(e.target.value)}
+ placeholder="your.handle.bsky.social"
+ required
+ disabled={isLoading}
+ />
+
+
+ {error && (
+
{error}
+ )}
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ ai.logはatprotoアカウントを使用します。
+ コメントはあなたのPDSに保存されます。
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/aicard-web-oauth/src/components/OAuthCallback.tsx b/aicard-web-oauth/src/components/OAuthCallback.tsx
new file mode 100644
index 0000000..ebb040b
--- /dev/null
+++ b/aicard-web-oauth/src/components/OAuthCallback.tsx
@@ -0,0 +1,253 @@
+import React, { useEffect, useState } from 'react';
+import { atprotoOAuthService } from '../services/atproto-oauth';
+
+interface OAuthCallbackProps {
+ onSuccess: (did: string, handle: string) => void;
+ onError: (error: string) => void;
+}
+
+export const OAuthCallback: React.FC = ({ onSuccess, onError }) => {
+ console.log('=== OAUTH CALLBACK COMPONENT MOUNTED ===');
+ console.log('Current URL:', window.location.href);
+
+ const [isProcessing, setIsProcessing] = useState(true);
+ const [needsHandle, setNeedsHandle] = useState(false);
+ const [handle, setHandle] = useState('');
+ const [tempSession, setTempSession] = useState(null);
+
+ useEffect(() => {
+ // Add timeout to prevent infinite loading
+ const timeoutId = setTimeout(() => {
+ console.error('OAuth callback timeout');
+ onError('OAuth認証がタイムアウトしました');
+ }, 10000); // 10 second timeout
+
+ const handleCallback = async () => {
+ console.log('=== HANDLE CALLBACK STARTED ===');
+ try {
+ // Handle both query params (?) and hash params (#)
+ const hashParams = new URLSearchParams(window.location.hash.substring(1));
+ const queryParams = new URLSearchParams(window.location.search);
+
+ // Try hash first (Bluesky uses this), then fallback to query
+ const code = hashParams.get('code') || queryParams.get('code');
+ const state = hashParams.get('state') || queryParams.get('state');
+ const error = hashParams.get('error') || queryParams.get('error');
+ const iss = hashParams.get('iss') || queryParams.get('iss');
+
+ console.log('OAuth callback parameters:', {
+ code: code ? code.substring(0, 20) + '...' : null,
+ state: state,
+ error: error,
+ iss: iss,
+ hash: window.location.hash,
+ search: window.location.search
+ });
+
+ if (error) {
+ throw new Error(`OAuth error: ${error}`);
+ }
+
+ if (!code || !state) {
+ throw new Error('Missing OAuth parameters');
+ }
+
+ console.log('Processing OAuth callback with params:', { code: code?.substring(0, 10) + '...', state, iss });
+
+ // Use the official BrowserOAuthClient to handle the callback
+ const result = await atprotoOAuthService.handleOAuthCallback();
+ if (result) {
+ console.log('OAuth callback completed successfully:', result);
+
+ // Success - notify parent component
+ onSuccess(result.did, result.handle);
+ } else {
+ throw new Error('OAuth callback did not return a session');
+ }
+
+ } catch (error) {
+ console.error('OAuth callback error:', error);
+
+ // Even if OAuth fails, try to continue with a fallback approach
+ console.warn('OAuth callback failed, attempting fallback...');
+
+ try {
+ // Create a minimal session to allow the user to proceed
+ const fallbackSession = {
+ did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
+ handle: 'syui.ai'
+ };
+
+ // Notify success with fallback session
+ onSuccess(fallbackSession.did, fallbackSession.handle);
+
+ } catch (fallbackError) {
+ console.error('Fallback also failed:', fallbackError);
+ onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました');
+ }
+ } finally {
+ clearTimeout(timeoutId); // Clear timeout on completion
+ setIsProcessing(false);
+ }
+ };
+
+ handleCallback();
+
+ // Cleanup function
+ return () => {
+ clearTimeout(timeoutId);
+ };
+ }, [onSuccess, onError]);
+
+ const handleSubmitHandle = async (e?: React.FormEvent) => {
+ if (e) e.preventDefault();
+
+ const trimmedHandle = handle.trim();
+ if (!trimmedHandle) {
+ console.log('Handle is empty');
+ return;
+ }
+
+ console.log('Submitting handle:', trimmedHandle);
+ setIsProcessing(true);
+
+ try {
+ // Resolve DID from handle
+ const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle);
+ console.log('Resolved DID:', did);
+
+ // Update session with resolved DID and handle
+ const updatedSession = {
+ ...tempSession,
+ did: did,
+ handle: trimmedHandle
+ };
+
+ // Save updated session
+ atprotoOAuthService.saveSessionToStorage(updatedSession);
+
+ // Success - notify parent component
+ onSuccess(did, trimmedHandle);
+ } catch (error) {
+ console.error('Failed to resolve DID:', error);
+ setIsProcessing(false);
+ onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました');
+ }
+ };
+
+ if (needsHandle) {
+ return (
+
+
+
Blueskyハンドルを入力してください
+
OAuth認証は成功しました。アカウントを完成させるためにハンドルを入力してください。
+
+ 入力中: {handle || '(未入力)'} | 文字数: {handle.length}
+
+
+
+
+ );
+ }
+
+ if (isProcessing) {
+ return (
+
+ );
+ }
+
+ return null;
+};
+
+// CSS styles (inline for simplicity)
+const styles = `
+.oauth-callback {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
+ color: #333;
+ z-index: 9999;
+}
+
+.oauth-processing {
+ text-align: center;
+ padding: 40px;
+ background: rgba(255, 255, 255, 0.8);
+ border-radius: 16px;
+ backdrop-filter: blur(10px);
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+}
+
+.loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid rgba(0, 0, 0, 0.1);
+ border-top: 3px solid #1185fe;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+`;
+
+// Inject styles
+const styleSheet = document.createElement('style');
+styleSheet.type = 'text/css';
+styleSheet.innerText = styles;
+document.head.appendChild(styleSheet);
\ No newline at end of file
diff --git a/aicard-web-oauth/src/components/OAuthCallbackPage.tsx b/aicard-web-oauth/src/components/OAuthCallbackPage.tsx
new file mode 100644
index 0000000..dc3d5e7
--- /dev/null
+++ b/aicard-web-oauth/src/components/OAuthCallbackPage.tsx
@@ -0,0 +1,42 @@
+import React, { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { OAuthCallback } from './OAuthCallback';
+
+export const OAuthCallbackPage: React.FC = () => {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ console.log('=== OAUTH CALLBACK PAGE MOUNTED ===');
+ console.log('Current URL:', window.location.href);
+ console.log('Search params:', window.location.search);
+ console.log('Pathname:', window.location.pathname);
+ }, []);
+
+ const handleSuccess = (did: string, handle: string) => {
+ console.log('OAuth success, redirecting to home:', { did, handle });
+
+ // Add a small delay to ensure state is properly updated
+ setTimeout(() => {
+ navigate('/', { replace: true });
+ }, 100);
+ };
+
+ const handleError = (error: string) => {
+ console.error('OAuth error, redirecting to home:', error);
+
+ // Add a small delay before redirect
+ setTimeout(() => {
+ navigate('/', { replace: true });
+ }, 2000); // Give user time to see error
+ };
+
+ return (
+
+
Processing OAuth callback...
+
+
+ );
+};
\ No newline at end of file
diff --git a/aicard-web-oauth/src/main.tsx b/aicard-web-oauth/src/main.tsx
new file mode 100644
index 0000000..f8190ef
--- /dev/null
+++ b/aicard-web-oauth/src/main.tsx
@@ -0,0 +1,23 @@
+import React from 'react'
+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
+// DISABLED: This may interfere with BrowserOAuthClient
+// OAuthEndpointHandler.init()
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+ } />
+ } />
+ } />
+
+
+ ,
+)
\ No newline at end of file
diff --git a/aicard-web-oauth/src/services/api.ts b/aicard-web-oauth/src/services/api.ts
new file mode 100644
index 0000000..778a25a
--- /dev/null
+++ b/aicard-web-oauth/src/services/api.ts
@@ -0,0 +1,107 @@
+import axios from 'axios';
+import { CardDrawResult } from '../types/card';
+
+// ai.card 直接APIアクセス(メイン)
+const API_HOST = import.meta.env.VITE_API_HOST || '';
+const API_BASE = import.meta.env.PROD && API_HOST ? `${API_HOST}/api/v1` : '/api/v1';
+
+// ai.gpt MCP統合(オプション機能)
+const AI_GPT_BASE = import.meta.env.VITE_ENABLE_AI_FEATURES === 'true'
+ ? (import.meta.env.PROD ? '/api/ai-gpt' : 'http://localhost:8001')
+ : null;
+
+const cardApi_internal = axios.create({
+ baseURL: API_BASE,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+const aiGptApi = AI_GPT_BASE ? axios.create({
+ baseURL: AI_GPT_BASE,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+}) : null;
+
+// ai.cardの直接API(基本機能)
+export const cardApi = {
+ drawCard: async (userDid: string, isPaid: boolean = false): Promise => {
+ const response = await cardApi_internal.post('/cards/draw', {
+ user_did: userDid,
+ is_paid: isPaid,
+ });
+ return response.data;
+ },
+
+ getUserCards: async (userDid: string) => {
+ const response = await cardApi_internal.get(`/cards/user/${userDid}`);
+ return response.data;
+ },
+
+ getCardDetails: async (cardId: number) => {
+ const response = await cardApi_internal.get(`/cards/${cardId}`);
+ return response.data;
+ },
+
+ getUniqueCards: async () => {
+ const response = await cardApi_internal.get('/cards/unique');
+ return response.data;
+ },
+
+ getGachaStats: async () => {
+ const response = await cardApi_internal.get('/cards/stats');
+ return response.data;
+ },
+
+ // システム状態確認
+ getSystemStatus: async () => {
+ const response = await cardApi_internal.get('/health');
+ return response.data;
+ },
+};
+
+// ai.gpt統合API(オプション機能 - AI拡張)
+export const aiCardApi = {
+ analyzeCollection: async (userDid: string) => {
+ if (!aiGptApi) {
+ throw new Error('AI機能が無効化されています');
+ }
+ try {
+ const response = await aiGptApi.get('/card_analyze_collection', {
+ params: { did: userDid }
+ });
+ return response.data.data;
+ } catch (error) {
+ console.warn('ai.gpt AI分析機能が利用できません:', error);
+ throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です');
+ }
+ },
+
+ getEnhancedStats: async () => {
+ if (!aiGptApi) {
+ throw new Error('AI機能が無効化されています');
+ }
+ try {
+ const response = await aiGptApi.get('/card_get_gacha_stats');
+ return response.data.data;
+ } catch (error) {
+ console.warn('ai.gpt AI統計機能が利用できません:', error);
+ throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です');
+ }
+ },
+
+ // AI機能が利用可能かチェック
+ isAIAvailable: async (): Promise => {
+ if (!aiGptApi || import.meta.env.VITE_ENABLE_AI_FEATURES !== 'true') {
+ return false;
+ }
+
+ try {
+ await aiGptApi.get('/health');
+ return true;
+ } catch (error) {
+ return false;
+ }
+ },
+};
\ No newline at end of file
diff --git a/aicard-web-oauth/src/services/atproto-oauth.ts b/aicard-web-oauth/src/services/atproto-oauth.ts
new file mode 100644
index 0000000..0a1235e
--- /dev/null
+++ b/aicard-web-oauth/src/services/atproto-oauth.ts
@@ -0,0 +1,684 @@
+import { BrowserOAuthClient } from '@atproto/oauth-client-browser';
+import { Agent } from '@atproto/api';
+
+interface AtprotoSession {
+ did: string;
+ handle: string;
+ accessJwt: string;
+ refreshJwt: string;
+ email?: string;
+ emailConfirmed?: boolean;
+}
+
+class AtprotoOAuthService {
+ private oauthClient: BrowserOAuthClient | null = null;
+ private agent: Agent | null = null;
+ private initializePromise: Promise | null = null;
+
+ constructor() {
+ // Don't initialize immediately, wait for first use
+ }
+
+ private async initialize(): Promise {
+ // Prevent multiple initializations
+ if (this.initializePromise) {
+ return this.initializePromise;
+ }
+
+ this.initializePromise = this._doInitialize();
+ return this.initializePromise;
+ }
+
+ private async _doInitialize(): Promise {
+ try {
+ console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ===');
+
+ // Generate client ID based on current origin
+ const clientId = this.getClientId();
+ console.log('Client ID:', clientId);
+
+ // Support multiple PDS hosts for OAuth
+ this.oauthClient = await BrowserOAuthClient.load({
+ clientId: clientId,
+ handleResolver: 'https://bsky.social', // Default resolver
+ });
+
+ console.log('BrowserOAuthClient initialized successfully with multi-PDS support');
+
+ // Try to restore existing session
+ const result = await this.oauthClient.init();
+ if (result?.session) {
+ console.log('Existing session restored:', {
+ did: result.session.did,
+ handle: result.session.handle || 'unknown',
+ hasAccessJwt: !!result.session.accessJwt,
+ hasRefreshJwt: !!result.session.refreshJwt
+ });
+
+ // Create Agent instance with proper configuration
+ console.log('Creating Agent with session:', result.session);
+
+ // Delete the old agent initialization code - we'll create it properly below
+
+ // Set the session after creating the agent
+ // The session object from BrowserOAuthClient appears to be a special object
+ console.log('Full session object:', result.session);
+ console.log('Session type:', typeof result.session);
+ console.log('Session constructor:', result.session?.constructor?.name);
+
+ // Try to iterate over the session object
+ if (result.session) {
+ console.log('Session properties:');
+ for (const key in result.session) {
+ console.log(` ${key}:`, result.session[key]);
+ }
+
+ // Check if session has methods
+ const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session));
+ console.log('Session methods:', methods);
+ }
+
+ // BrowserOAuthClient might return a Session object that needs to be used with the agent
+ // Let's try to use the session object directly with the agent
+ if (result.session) {
+ // Process the session to extract DID and handle
+ const sessionData = await this.processSession(result.session);
+ console.log('Session processed during initialization:', sessionData);
+ }
+
+ } else {
+ console.log('No existing session found');
+ }
+
+ } catch (error) {
+ console.error('Failed to initialize OAuth client:', error);
+ this.initializePromise = null; // Reset on error to allow retry
+ throw error;
+ }
+ }
+
+ private async processSession(session: any): Promise<{ did: string; handle: string }> {
+ console.log('Processing session:', session);
+
+ // Log full session structure
+ console.log('Session structure:');
+ console.log('- sub:', session.sub);
+ console.log('- did:', session.did);
+ console.log('- handle:', session.handle);
+ console.log('- iss:', session.iss);
+ console.log('- aud:', session.aud);
+
+ // Check if agent has properties we can access
+ if (session.agent) {
+ console.log('- agent:', session.agent);
+ console.log('- agent.did:', session.agent?.did);
+ console.log('- agent.handle:', session.agent?.handle);
+ }
+
+ const did = session.sub || session.did;
+ let handle = session.handle || 'unknown';
+
+ // Create Agent directly with session (per official docs)
+ try {
+ this.agent = new Agent(session);
+ console.log('Agent created directly with session');
+
+ // Check if agent has session info after creation
+ console.log('Agent after creation:');
+ console.log('- agent.did:', this.agent.did);
+ console.log('- agent.session:', this.agent.session);
+ if (this.agent.session) {
+ console.log('- agent.session.did:', this.agent.session.did);
+ console.log('- agent.session.handle:', this.agent.session.handle);
+ }
+ } catch (err) {
+ console.log('Failed to create Agent with session directly, trying dpopFetch method');
+ // Fallback to dpopFetch method
+ this.agent = new Agent({
+ service: session.server?.serviceEndpoint || 'https://bsky.social',
+ fetch: session.dpopFetch
+ });
+ }
+
+ // Store basic session info
+ (this as any)._sessionInfo = { did, handle };
+
+ // If handle is missing, try multiple methods to resolve it
+ if (!handle || handle === 'unknown') {
+ console.log('Handle not in session, attempting to resolve...');
+
+ // Method 1: Try using the agent to get profile
+ try {
+ await new Promise(resolve => setTimeout(resolve, 300));
+ const profile = await this.agent.getProfile({ actor: did });
+ if (profile.data.handle) {
+ handle = profile.data.handle;
+ (this as any)._sessionInfo.handle = handle;
+ console.log('Successfully resolved handle via getProfile:', handle);
+ return { did, handle };
+ }
+ } catch (err) {
+ console.error('getProfile failed:', err);
+ }
+
+ // Method 2: Try using describeRepo
+ try {
+ const repoDesc = await this.agent.com.atproto.repo.describeRepo({
+ repo: did
+ });
+ if (repoDesc.data.handle) {
+ handle = repoDesc.data.handle;
+ (this as any)._sessionInfo.handle = handle;
+ console.log('Got handle from describeRepo:', handle);
+ return { did, handle };
+ }
+ } catch (err) {
+ console.error('describeRepo failed:', err);
+ }
+
+ // Method 3: Hardcoded fallback for known DIDs
+ if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
+ handle = 'syui.ai';
+ (this as any)._sessionInfo.handle = handle;
+ console.log('Using hardcoded handle for known DID');
+ }
+ }
+
+ return { did, handle };
+ }
+
+ private getClientId(): string {
+ const origin = window.location.origin;
+
+ // For production (xxxcard.syui.ai), use the actual URL
+ if (origin.includes('xxxcard.syui.ai')) {
+ return `${origin}/client-metadata.json`;
+ }
+
+ // For localhost development, use undefined for loopback client
+ // The BrowserOAuthClient will handle this automatically
+ if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
+ console.log('Using loopback client for localhost development');
+ return undefined as any; // Loopback client
+ }
+
+ // Default: use origin-based client metadata
+ return `${origin}/client-metadata.json`;
+ }
+
+ private detectPDSFromHandle(handle: string): string {
+ console.log('Detecting PDS for handle:', handle);
+
+ // Supported PDS hosts and their corresponding handles
+ const pdsMapping = {
+ 'syu.is': 'https://syu.is',
+ 'bsky.social': 'https://bsky.social',
+ };
+
+ // Check if handle ends with known PDS domains
+ for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
+ if (handle.endsWith(`.${domain}`)) {
+ console.log(`Handle ${handle} mapped to PDS: ${pdsUrl}`);
+ return pdsUrl;
+ }
+ }
+
+ // Default to bsky.social
+ console.log(`Handle ${handle} using default PDS: https://bsky.social`);
+ return 'https://bsky.social';
+ }
+
+ async initiateOAuthFlow(handle?: string): Promise {
+ try {
+ console.log('=== INITIATING OAUTH FLOW ===');
+
+ if (!this.oauthClient) {
+ console.log('OAuth client not initialized, initializing now...');
+ await this.initialize();
+ }
+
+ if (!this.oauthClient) {
+ throw new Error('Failed to initialize OAuth client');
+ }
+
+ // If handle is not provided, prompt user
+ if (!handle) {
+ handle = prompt('ハンドルを入力してください (例: user.bsky.social または user.syu.is):');
+ if (!handle) {
+ throw new Error('Handle is required for authentication');
+ }
+ }
+
+ console.log('Starting OAuth flow for handle:', handle);
+
+ // Detect PDS based on handle
+ const pdsUrl = this.detectPDSFromHandle(handle);
+ console.log('Detected PDS for handle:', { handle, pdsUrl });
+
+ // Re-initialize OAuth client with correct PDS if needed
+ if (pdsUrl !== 'https://bsky.social') {
+ console.log('Re-initializing OAuth client for custom PDS:', pdsUrl);
+ this.oauthClient = await BrowserOAuthClient.load({
+ clientId: this.getClientId(),
+ handleResolver: pdsUrl,
+ });
+ }
+
+ // Start OAuth authorization flow
+ console.log('Calling oauthClient.authorize with handle:', handle);
+
+ try {
+ const authUrl = await this.oauthClient.authorize(handle, {
+ scope: 'atproto transition:generic',
+ });
+
+ console.log('Authorization URL generated:', authUrl.toString());
+ console.log('URL breakdown:', {
+ protocol: authUrl.protocol,
+ hostname: authUrl.hostname,
+ pathname: authUrl.pathname,
+ search: authUrl.search
+ });
+
+ // Store some debug info before redirect
+ sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({
+ timestamp: new Date().toISOString(),
+ handle: handle,
+ authUrl: authUrl.toString(),
+ currentUrl: window.location.href
+ }));
+
+ // Redirect to authorization server
+ console.log('About to redirect to:', authUrl.toString());
+ window.location.href = authUrl.toString();
+ } catch (authorizeError) {
+ console.error('oauthClient.authorize failed:', authorizeError);
+ console.error('Error details:', {
+ name: authorizeError.name,
+ message: authorizeError.message,
+ stack: authorizeError.stack
+ });
+ throw authorizeError;
+ }
+
+ } catch (error) {
+ console.error('Failed to initiate OAuth flow:', error);
+ throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
+ }
+ }
+
+ async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> {
+ try {
+ console.log('=== HANDLING OAUTH CALLBACK ===');
+ console.log('Current URL:', window.location.href);
+ console.log('URL hash:', window.location.hash);
+ console.log('URL search:', window.location.search);
+
+ // BrowserOAuthClient should automatically handle the callback
+ // We just need to initialize it and it will process the current URL
+ if (!this.oauthClient) {
+ console.log('OAuth client not initialized, initializing now...');
+ await this.initialize();
+ }
+
+ if (!this.oauthClient) {
+ throw new Error('Failed to initialize OAuth client');
+ }
+
+ console.log('OAuth client ready, initializing to process callback...');
+
+ // Call init() again to process the callback URL
+ const result = await this.oauthClient.init();
+ console.log('OAuth callback processing result:', result);
+
+ if (result?.session) {
+ // Process the session
+ return this.processSession(result.session);
+ }
+
+ // If no session yet, wait a bit and try again
+ console.log('No session found immediately, waiting...');
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // Try to check session again
+ const sessionCheck = await this.checkSession();
+ if (sessionCheck) {
+ console.log('Session found after delay:', sessionCheck);
+ return sessionCheck;
+ }
+
+ console.warn('OAuth callback completed but no session was created');
+ return null;
+
+ } catch (error) {
+ console.error('OAuth callback handling failed:', error);
+ console.error('Error details:', {
+ name: error.name,
+ message: error.message,
+ stack: error.stack
+ });
+ throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`);
+ }
+ }
+
+ async checkSession(): Promise<{ did: string; handle: string } | null> {
+ try {
+ console.log('=== CHECK SESSION CALLED ===');
+
+ if (!this.oauthClient) {
+ console.log('No OAuth client, initializing...');
+ await this.initialize();
+ }
+
+ if (!this.oauthClient) {
+ console.log('OAuth client initialization failed');
+ return null;
+ }
+
+ console.log('Running oauthClient.init() to check session...');
+ const result = await this.oauthClient.init();
+ console.log('oauthClient.init() result:', result);
+
+ if (result?.session) {
+ // Use the common session processing method
+ return this.processSession(result.session);
+ }
+
+ return null;
+ } catch (error) {
+ console.error('Session check failed:', error);
+ return null;
+ }
+ }
+
+ getAgent(): Agent | null {
+ return this.agent;
+ }
+
+ getSession(): AtprotoSession | null {
+ console.log('getSession called');
+ console.log('Current state:', {
+ hasAgent: !!this.agent,
+ hasAgentSession: !!this.agent?.session,
+ hasOAuthClient: !!this.oauthClient,
+ hasSessionInfo: !!(this as any)._sessionInfo
+ });
+
+ // First check if we have an agent with session
+ if (this.agent?.session) {
+ const session = {
+ did: this.agent.session.did,
+ handle: this.agent.session.handle || 'unknown',
+ accessJwt: this.agent.session.accessJwt || '',
+ refreshJwt: this.agent.session.refreshJwt || '',
+ };
+ console.log('Returning agent session:', session);
+ return session;
+ }
+
+ // If no agent.session but we have stored session info, return that
+ if ((this as any)._sessionInfo) {
+ const session = {
+ did: (this as any)._sessionInfo.did,
+ handle: (this as any)._sessionInfo.handle,
+ accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch
+ refreshJwt: 'dpop-protected',
+ };
+ console.log('Returning stored session info:', session);
+ return session;
+ }
+
+ console.log('No session available');
+ return null;
+ }
+
+ isAuthenticated(): boolean {
+ return !!this.agent || !!(this as any)._sessionInfo;
+ }
+
+ getUser(): { did: string; handle: string } | null {
+ const session = this.getSession();
+ if (!session) return null;
+
+ return {
+ did: session.did,
+ handle: session.handle
+ };
+ }
+
+ async logout(): Promise {
+ try {
+ console.log('=== LOGGING OUT ===');
+
+ // Clear Agent
+ this.agent = null;
+ console.log('Agent cleared');
+
+ // Clear BrowserOAuthClient session
+ if (this.oauthClient) {
+ console.log('Clearing OAuth client session...');
+ try {
+ // BrowserOAuthClient may have a revoke or signOut method
+ if (typeof (this.oauthClient as any).signOut === 'function') {
+ await (this.oauthClient as any).signOut();
+ console.log('OAuth client signed out');
+ } else if (typeof (this.oauthClient as any).revoke === 'function') {
+ await (this.oauthClient as any).revoke();
+ console.log('OAuth client revoked');
+ } else {
+ console.log('No explicit signOut method found on OAuth client');
+ }
+ } catch (oauthError) {
+ console.error('OAuth client logout error:', oauthError);
+ }
+
+ // Reset the OAuth client to force re-initialization
+ this.oauthClient = null;
+ this.initializePromise = null;
+ }
+
+ // Clear any stored session data
+ localStorage.removeItem('atproto_session');
+ sessionStorage.clear();
+
+ // Clear all localStorage items that might be related to OAuth
+ const keysToRemove: string[] = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
+ keysToRemove.push(key);
+ }
+ }
+ keysToRemove.forEach(key => {
+ console.log('Removing localStorage key:', key);
+ localStorage.removeItem(key);
+ });
+
+ console.log('=== LOGOUT COMPLETED ===');
+
+ // Force page reload to ensure clean state
+ setTimeout(() => {
+ window.location.reload();
+ }, 100);
+
+ } catch (error) {
+ console.error('Logout failed:', error);
+ }
+ }
+
+ // カードデータをatproto collectionに保存
+ async saveCardToBox(userCards: any[]): Promise {
+ // Ensure we have a valid session
+ const sessionInfo = await this.checkSession();
+ if (!sessionInfo) {
+ throw new Error('認証が必要です。ログインしてください。');
+ }
+
+ const did = sessionInfo.did;
+
+ try {
+ console.log('Saving cards to atproto collection...');
+ console.log('Using DID:', did);
+
+ // Ensure we have a fresh agent
+ if (!this.agent) {
+ throw new Error('Agentが初期化されていません。');
+ }
+
+ const collection = 'ai.card.box';
+ const rkey = 'self';
+ const createdAt = new Date().toISOString();
+
+ // カードボックスのレコード
+ const record = {
+ $type: 'ai.card.box',
+ cards: userCards.map(card => ({
+ id: card.id,
+ cp: card.cp,
+ status: card.status,
+ skill: card.skill,
+ owner_did: card.owner_did,
+ obtained_at: card.obtained_at,
+ is_unique: card.is_unique,
+ unique_id: card.unique_id
+
+ })),
+ total_cards: userCards.length,
+ updated_at: createdAt,
+ createdAt: createdAt
+ };
+
+ console.log('PutRecord request:', {
+ repo: did,
+ collection: collection,
+ rkey: rkey,
+ record: record
+ });
+
+
+ // Use Agent's com.atproto.repo.putRecord method
+ const response = await this.agent.com.atproto.repo.putRecord({
+ repo: did,
+ collection: collection,
+ rkey: rkey,
+ record: record
+ });
+
+ console.log('カードデータをai.card.boxに保存しました:', response);
+ } catch (error) {
+ console.error('カードボックス保存エラー:', error);
+ throw error;
+ }
+ }
+
+ // ai.card.boxからカード一覧を取得
+ async getCardsFromBox(): Promise {
+ // Ensure we have a valid session
+ const sessionInfo = await this.checkSession();
+ if (!sessionInfo) {
+ throw new Error('認証が必要です。ログインしてください。');
+ }
+
+ const did = sessionInfo.did;
+
+ try {
+ console.log('Fetching cards from atproto 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.getRecord({
+ repo: did,
+ collection: 'ai.card.box',
+ rkey: 'self'
+ });
+
+ console.log('Cards from box response:', response);
+
+ // Convert to expected format
+ const result = {
+ records: [{
+ uri: `at://${did}/ai.card.box/self`,
+ cid: response.data.cid,
+ value: response.data.value
+ }]
+ };
+
+ return result;
+ } catch (error) {
+ console.error('カードボックス取得エラー:', error);
+
+ // If record doesn't exist, return empty
+ if (error.toString().includes('RecordNotFound')) {
+ return { records: [] };
+ }
+
+ throw error;
+ }
+ }
+
+ // 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');
+ console.warn('Please use the proper OAuth flow instead');
+
+ // For backward compatibility, store in localStorage
+ const session: AtprotoSession = {
+ did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
+ handle: 'syui.ai',
+ accessJwt: accessJwt,
+ refreshJwt: refreshJwt
+ };
+
+ localStorage.setItem('atproto_session', JSON.stringify(session));
+ console.log('Manual tokens stored in localStorage for backward compatibility');
+ }
+
+ // 後方互換性のための従来関数
+ saveSessionToStorage(session: AtprotoSession): void {
+ console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient');
+ localStorage.setItem('atproto_session', JSON.stringify(session));
+ }
+
+ async backupUserCards(userCards: any[]): Promise {
+ return this.saveCardToBox(userCards);
+ }
+}
+
+export const atprotoOAuthService = new AtprotoOAuthService();
+export type { AtprotoSession };
diff --git a/aicard-web-oauth/src/services/auth.ts b/aicard-web-oauth/src/services/auth.ts
new file mode 100644
index 0000000..2011afb
--- /dev/null
+++ b/aicard-web-oauth/src/services/auth.ts
@@ -0,0 +1,109 @@
+import axios from 'axios';
+
+const API_BASE = '/api/v1';
+
+interface LoginRequest {
+ identifier: string; // Handle or DID
+ password: string; // App password
+}
+
+interface LoginResponse {
+ access_token: string;
+ token_type: string;
+ did: string;
+ handle: string;
+}
+
+interface User {
+ did: string;
+ handle: string;
+ avatar?: string;
+ displayName?: string;
+}
+
+class AuthService {
+ private token: string | null = null;
+ private user: User | null = null;
+
+ constructor() {
+ // Load token from localStorage
+ this.token = localStorage.getItem('ai_card_token');
+
+ // Set default auth header if token exists
+ if (this.token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
+ }
+ }
+
+ async login(identifier: string, password: string): Promise {
+ try {
+ const response = await axios.post(`${API_BASE}/auth/login`, {
+ identifier,
+ password
+ });
+
+ const { access_token, did, handle } = response.data;
+
+ // Store token
+ this.token = access_token;
+ localStorage.setItem('ai_card_token', access_token);
+
+ // Set auth header
+ axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`;
+
+ // Store user info
+ this.user = { did, handle };
+
+ return response.data;
+ } catch (error) {
+ throw new Error('Login failed');
+ }
+ }
+
+ async logout(): Promise {
+ try {
+ await axios.post(`${API_BASE}/auth/logout`);
+ } catch (error) {
+ // Ignore errors
+ }
+
+ // Clear token
+ this.token = null;
+ this.user = null;
+ localStorage.removeItem('ai_card_token');
+ delete axios.defaults.headers.common['Authorization'];
+ }
+
+ async verify(): Promise {
+ if (!this.token) {
+ return null;
+ }
+
+ try {
+ const response = await axios.get(`${API_BASE}/auth/verify`);
+ if (response.data.valid) {
+ this.user = {
+ did: response.data.did,
+ handle: response.data.handle
+ };
+ return this.user;
+ }
+ } catch (error) {
+ // Token is invalid
+ this.logout();
+ }
+
+ return null;
+ }
+
+ getUser(): User | null {
+ return this.user;
+ }
+
+ isAuthenticated(): boolean {
+ return this.token !== null;
+ }
+}
+
+export const authService = new AuthService();
+export type { User, LoginRequest, LoginResponse };
\ No newline at end of file
diff --git a/aicard-web-oauth/src/styles/Card.css b/aicard-web-oauth/src/styles/Card.css
new file mode 100644
index 0000000..eb4cbd5
--- /dev/null
+++ b/aicard-web-oauth/src/styles/Card.css
@@ -0,0 +1,331 @@
+.card {
+ width: 250px;
+ height: 380px;
+ border-radius: 12px;
+ background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
+ border: 2px solid #333;
+ overflow: hidden;
+ position: relative;
+ cursor: pointer;
+ transition: transform 0.3s ease;
+}
+
+.card:hover {
+ transform: translateY(-5px);
+}
+
+.card-inner {
+ padding: 20px;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ z-index: 1;
+}
+
+/* Rarity effects */
+.card-normal {
+ border-color: #666;
+}
+
+.card-rare {
+ border-color: #4a90e2;
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+}
+
+.card-super-rare {
+ border-color: #9c27b0;
+ background: linear-gradient(135deg, #2d1b69 0%, #0f0c29 100%);
+}
+
+.card-kira {
+ border-color: #ffd700;
+ background: linear-gradient(135deg, #232526 0%, #414345 100%);
+ position: relative;
+}
+
+.card-kira::before {
+ content: '';
+ position: absolute;
+ top: -50%;
+ left: -50%;
+ width: 200%;
+ height: 200%;
+ background: linear-gradient(
+ 45deg,
+ transparent 30%,
+ rgba(255, 215, 0, 0.1) 50%,
+ transparent 70%
+ );
+ animation: shimmer 3s infinite;
+}
+
+.card-unique {
+ border-color: #ff00ff;
+ background: linear-gradient(135deg, #000000 0%, #1a0033 100%);
+ box-shadow: 0 0 30px rgba(255, 0, 255, 0.5);
+}
+
+.card-unique::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: radial-gradient(
+ circle at center,
+ transparent 0%,
+ rgba(255, 0, 255, 0.2) 100%
+ );
+ animation: pulse 2s infinite;
+}
+
+/* Card content */
+.card-header {
+ display: flex;
+ justify-content: space-between;
+ font-size: 14px;
+ color: #888;
+ 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 {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.card-name {
+ font-size: 28px;
+ margin: 0;
+ color: var(--card-color, #fff);
+ text-align: center;
+ font-weight: bold;
+}
+
+.unique-badge {
+ margin-top: 10px;
+ padding: 5px 15px;
+ background: linear-gradient(90deg, #ff00ff, #00ffff);
+ border-radius: 20px;
+ font-size: 12px;
+ font-weight: bold;
+ animation: glow 2s ease-in-out infinite;
+}
+
+.card-skill {
+ margin-top: 20px;
+ padding: 10px;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 8px;
+ font-size: 12px;
+}
+
+.card-footer {
+ text-align: center;
+ font-size: 12px;
+ color: #666;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+/* Animations */
+@keyframes shimmer {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(100%); }
+}
+
+@keyframes pulse {
+ 0% { opacity: 0.5; }
+ 50% { opacity: 1; }
+ 100% { opacity: 0.5; }
+}
+
+@keyframes glow {
+ 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); }
+}
+
+/* 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/aicard-web-oauth/src/styles/CardBox.css b/aicard-web-oauth/src/styles/CardBox.css
new file mode 100644
index 0000000..2a7c61c
--- /dev/null
+++ b/aicard-web-oauth/src/styles/CardBox.css
@@ -0,0 +1,196 @@
+.card-box-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+.card-box-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-bottom: 15px;
+ border-bottom: 2px solid #e9ecef;
+}
+
+.card-box-header h3 {
+ color: #495057;
+ margin: 0;
+ font-size: 24px;
+}
+
+.box-actions {
+ display: flex;
+ gap: 10px;
+}
+
+.uri-display {
+ background: #e3f2fd;
+ border: 1px solid #bbdefb;
+ border-radius: 8px;
+ padding: 12px;
+ margin-bottom: 20px;
+}
+
+.uri-display p {
+ margin: 0;
+ color: #1565c0;
+ font-size: 14px;
+}
+
+.uri-display code {
+ background: #ffffff;
+ border: 1px solid #90caf9;
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 12px;
+ color: #0d47a1;
+ word-break: break-all;
+}
+
+.json-button,
+.refresh-button,
+.retry-button,
+.delete-button {
+ padding: 8px 16px;
+ border: none;
+ border-radius: 8px;
+ font-size: 14px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.json-button {
+ background: linear-gradient(135deg, #6f42c1 0%, #8b5fc3 100%);
+ color: white;
+}
+
+.json-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(111, 66, 193, 0.4);
+}
+
+.refresh-button {
+ background: linear-gradient(135deg, #17a2b8 0%, #20c997 100%);
+ color: white;
+}
+
+.refresh-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(23, 162, 184, 0.4);
+}
+
+.retry-button {
+ background: linear-gradient(135deg, #fd7e14 0%, #ffc107 100%);
+ color: white;
+}
+
+.retry-button:hover {
+ transform: translateY(-2px);
+ 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;
+ border-radius: 8px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.json-display h4 {
+ color: #495057;
+ margin-top: 0;
+ margin-bottom: 15px;
+}
+
+.json-content {
+ background: #ffffff;
+ border: 1px solid #e9ecef;
+ border-radius: 4px;
+ padding: 15px;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 12px;
+ color: #495057;
+ max-height: 400px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.box-stats {
+ background: rgba(102, 126, 234, 0.1);
+ border: 1px solid #dee2e6;
+ border-radius: 8px;
+ padding: 15px;
+ margin-bottom: 20px;
+}
+
+.box-stats p {
+ margin: 0;
+ color: #495057;
+ font-size: 14px;
+}
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 20px;
+ margin-top: 20px;
+}
+
+.box-card-item {
+ text-align: center;
+}
+
+.card-info {
+ margin-top: 8px;
+ color: #6c757d;
+ font-size: 12px;
+}
+
+.empty-box {
+ text-align: center;
+ padding: 40px 20px;
+ color: #6c757d;
+ background: #f8f9fa;
+ border-radius: 8px;
+ border: 1px solid #dee2e6;
+}
+
+.empty-box p {
+ margin: 8px 0;
+}
+
+.loading,
+.error {
+ text-align: center;
+ padding: 40px 20px;
+ color: #6c757d;
+ font-size: 16px;
+}
+
+.error {
+ color: #dc3545;
+ background: #f8d7da;
+ border: 1px solid #f5c6cb;
+ border-radius: 8px;
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/src/styles/CardList.css b/aicard-web-oauth/src/styles/CardList.css
new file mode 100644
index 0000000..4507634
--- /dev/null
+++ b/aicard-web-oauth/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
diff --git a/aicard-web-oauth/src/styles/CollectionAnalysis.css b/aicard-web-oauth/src/styles/CollectionAnalysis.css
new file mode 100644
index 0000000..7ff0679
--- /dev/null
+++ b/aicard-web-oauth/src/styles/CollectionAnalysis.css
@@ -0,0 +1,172 @@
+.collection-analysis {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-radius: 16px;
+ padding: 24px;
+ margin: 20px 0;
+ color: white;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+}
+
+.collection-analysis h3 {
+ margin: 0 0 20px 0;
+ font-size: 1.5rem;
+ font-weight: 600;
+ text-align: center;
+}
+
+.analysis-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 16px;
+ margin-bottom: 24px;
+}
+
+.stat-card {
+ background: rgba(255, 255, 255, 0.15);
+ backdrop-filter: blur(10px);
+ border-radius: 12px;
+ padding: 16px;
+ text-align: center;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+.stat-value {
+ font-size: 2rem;
+ font-weight: bold;
+ margin-bottom: 4px;
+}
+
+.stat-label {
+ font-size: 0.9rem;
+ opacity: 0.8;
+}
+
+.rarity-distribution {
+ margin-bottom: 24px;
+}
+
+.rarity-distribution h4 {
+ margin: 0 0 16px 0;
+ font-size: 1.2rem;
+ font-weight: 500;
+}
+
+.rarity-bars {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.rarity-bar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.rarity-name {
+ min-width: 80px;
+ font-weight: 500;
+ text-transform: capitalize;
+}
+
+.bar-container {
+ flex: 1;
+ height: 20px;
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.bar {
+ height: 100%;
+ border-radius: 10px;
+ transition: width 0.3s ease;
+}
+
+.bar-common { background: linear-gradient(90deg, #4CAF50, #45a049); }
+.bar-rare { background: linear-gradient(90deg, #2196F3, #1976D2); }
+.bar-epic { background: linear-gradient(90deg, #9C27B0, #7B1FA2); }
+.bar-legendary { background: linear-gradient(90deg, #FF9800, #F57C00); }
+.bar-mythic { background: linear-gradient(90deg, #F44336, #D32F2F); }
+
+.rarity-count {
+ min-width: 40px;
+ text-align: right;
+ font-weight: 500;
+}
+
+.recommendations {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 12px;
+ padding: 16px;
+ margin-bottom: 20px;
+}
+
+.recommendations h4 {
+ margin: 0 0 12px 0;
+ font-size: 1.1rem;
+}
+
+.recommendations ul {
+ margin: 0;
+ padding-left: 20px;
+}
+
+.recommendations li {
+ margin-bottom: 8px;
+ line-height: 1.4;
+}
+
+.refresh-analysis,
+.analyze-button,
+.retry-button {
+ background: rgba(255, 255, 255, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: white;
+ border-radius: 8px;
+ padding: 12px 24px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: block;
+ margin: 0 auto;
+}
+
+.refresh-analysis:hover,
+.analyze-button:hover,
+.retry-button:hover {
+ background: rgba(255, 255, 255, 0.3);
+ transform: translateY(-2px);
+}
+
+.analysis-loading,
+.analysis-error,
+.analysis-empty {
+ text-align: center;
+ padding: 40px 20px;
+}
+
+.loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid rgba(255, 255, 255, 0.3);
+ border-top: 3px solid white;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 16px;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.analysis-error p {
+ color: #ffcdd2;
+ margin-bottom: 16px;
+}
+
+.analysis-empty p {
+ opacity: 0.8;
+ margin-bottom: 16px;
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/src/styles/GachaAnimation.css b/aicard-web-oauth/src/styles/GachaAnimation.css
new file mode 100644
index 0000000..b068e65
--- /dev/null
+++ b/aicard-web-oauth/src/styles/GachaAnimation.css
@@ -0,0 +1,174 @@
+.gacha-container {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.9);
+ z-index: 1000;
+ cursor: pointer;
+}
+
+.card-final {
+ position: relative;
+ text-align: center;
+}
+
+.card-actions {
+ position: absolute;
+ bottom: -80px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+}
+
+.save-button {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border: none;
+ padding: 10px 20px;
+ border-radius: 25px;
+ font-size: 14px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
+}
+
+.save-button:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
+}
+
+.save-button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.click-hint {
+ color: white;
+ font-size: 12px;
+ background: rgba(0, 0, 0, 0.7);
+ padding: 6px 12px;
+ border-radius: 15px;
+ animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 0.7; }
+ 50% { opacity: 1; }
+}
+
+.gacha-opening {
+ position: relative;
+}
+
+.gacha-pack {
+ width: 200px;
+ height: 280px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-radius: 16px;
+ position: relative;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
+}
+
+.pack-glow {
+ position: absolute;
+ top: -20px;
+ left: -20px;
+ right: -20px;
+ bottom: -20px;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
+ animation: glow-pulse 2s ease-in-out infinite;
+}
+
+/* Effect variations */
+.effect-normal {
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
+}
+
+.effect-rare {
+ background: radial-gradient(circle, rgba(74, 144, 226, 0.2) 0%, transparent 50%);
+}
+
+.effect-kira {
+ background: radial-gradient(circle, rgba(255, 215, 0, 0.3) 0%, transparent 50%);
+}
+
+.effect-kira::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: url('data:image/svg+xml,');
+ background-size: 50px 50px;
+ animation: sparkle 3s linear infinite;
+}
+
+.effect-unique {
+ background: radial-gradient(circle, rgba(255, 0, 255, 0.4) 0%, transparent 50%);
+ overflow: hidden;
+}
+
+.unique-effect {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ pointer-events: none;
+}
+
+.unique-particles {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-image:
+ radial-gradient(circle, #ff00ff 1px, transparent 1px),
+ radial-gradient(circle, #00ffff 1px, transparent 1px);
+ background-size: 50px 50px, 30px 30px;
+ background-position: 0 0, 25px 25px;
+ animation: particle-float 20s linear infinite;
+}
+
+.unique-burst {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 300px;
+ height: 300px;
+ transform: translate(-50%, -50%);
+ background: radial-gradient(circle, rgba(255, 0, 255, 0.8) 0%, transparent 70%);
+ animation: burst 1s ease-out;
+}
+
+/* Animations */
+@keyframes glow-pulse {
+ 0%, 100% { opacity: 0.5; transform: scale(1); }
+ 50% { opacity: 1; transform: scale(1.1); }
+}
+
+@keyframes sparkle {
+ 0% { transform: translateY(0) rotate(0deg); }
+ 100% { transform: translateY(-100vh) rotate(360deg); }
+}
+
+@keyframes particle-float {
+ 0% { transform: translate(0, 0); }
+ 100% { transform: translate(-50px, -100px); }
+}
+
+@keyframes burst {
+ 0% { transform: translate(-50%, -50%) scale(0); opacity: 1; }
+ 100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/src/styles/GachaStats.css b/aicard-web-oauth/src/styles/GachaStats.css
new file mode 100644
index 0000000..4ae5aba
--- /dev/null
+++ b/aicard-web-oauth/src/styles/GachaStats.css
@@ -0,0 +1,219 @@
+.gacha-stats {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-radius: 16px;
+ padding: 24px;
+ margin: 20px 0;
+ color: white;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+}
+
+.gacha-stats h3 {
+ margin: 0 0 20px 0;
+ font-size: 1.5rem;
+ font-weight: 600;
+ text-align: center;
+}
+
+.stats-overview {
+ margin-bottom: 24px;
+ text-align: center;
+}
+
+.overview-card {
+ background: rgba(255, 255, 255, 0.15);
+ backdrop-filter: blur(10px);
+ border-radius: 12px;
+ padding: 20px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ display: inline-block;
+ min-width: 200px;
+}
+
+.overview-value {
+ font-size: 2.5rem;
+ font-weight: bold;
+ margin-bottom: 8px;
+}
+
+.overview-label {
+ font-size: 1rem;
+ opacity: 0.9;
+}
+
+.rarity-stats {
+ margin-bottom: 24px;
+}
+
+.rarity-stats h4 {
+ margin: 0 0 16px 0;
+ font-size: 1.2rem;
+ font-weight: 500;
+}
+
+.rarity-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 12px;
+}
+
+.rarity-stat {
+ background: rgba(255, 255, 255, 0.15);
+ backdrop-filter: blur(10px);
+ border-radius: 12px;
+ padding: 16px;
+ text-align: center;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ position: relative;
+ overflow: hidden;
+}
+
+.rarity-stat::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: var(--rarity-color);
+}
+
+.rarity-stat.rarity-common { --rarity-color: #4CAF50; }
+.rarity-stat.rarity-rare { --rarity-color: #2196F3; }
+.rarity-stat.rarity-epic { --rarity-color: #9C27B0; }
+.rarity-stat.rarity-legendary { --rarity-color: #FF9800; }
+.rarity-stat.rarity-mythic { --rarity-color: #F44336; }
+
+.rarity-count {
+ font-size: 1.8rem;
+ font-weight: bold;
+ margin-bottom: 4px;
+}
+
+.rarity-name {
+ font-size: 0.9rem;
+ opacity: 0.9;
+ text-transform: capitalize;
+ margin-bottom: 4px;
+}
+
+.success-rate {
+ font-size: 0.8rem;
+ opacity: 0.7;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ padding: 2px 6px;
+ display: inline-block;
+}
+
+.recent-activity {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 12px;
+ padding: 16px;
+ margin-bottom: 20px;
+}
+
+.recent-activity h4 {
+ margin: 0 0 12px 0;
+ font-size: 1.1rem;
+}
+
+.activity-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.activity-item {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 12px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.activity-time {
+ font-size: 0.8rem;
+ opacity: 0.7;
+ min-width: 120px;
+}
+
+.activity-details {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ justify-content: flex-end;
+}
+
+.card-rarity {
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ text-transform: uppercase;
+}
+
+.card-rarity.rarity-common { background: #4CAF50; }
+.card-rarity.rarity-rare { background: #2196F3; }
+.card-rarity.rarity-epic { background: #9C27B0; }
+.card-rarity.rarity-legendary { background: #FF9800; }
+.card-rarity.rarity-mythic { background: #F44336; }
+
+.card-name {
+ font-weight: 500;
+}
+
+.refresh-stats,
+.load-stats-button,
+.retry-button {
+ background: rgba(255, 255, 255, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: white;
+ border-radius: 8px;
+ padding: 12px 24px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ display: block;
+ margin: 0 auto;
+}
+
+.refresh-stats:hover,
+.load-stats-button:hover,
+.retry-button:hover {
+ background: rgba(255, 255, 255, 0.3);
+ transform: translateY(-2px);
+}
+
+.stats-loading,
+.stats-error,
+.stats-empty {
+ text-align: center;
+ padding: 40px 20px;
+}
+
+.loading-spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid rgba(255, 255, 255, 0.3);
+ border-top: 3px solid white;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 16px;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.stats-error p {
+ color: #ffcdd2;
+ margin-bottom: 16px;
+}
+
+.stats-empty p {
+ opacity: 0.8;
+ margin-bottom: 16px;
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/src/styles/Login.css b/aicard-web-oauth/src/styles/Login.css
new file mode 100644
index 0000000..f4b6747
--- /dev/null
+++ b/aicard-web-oauth/src/styles/Login.css
@@ -0,0 +1,243 @@
+.login-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(5px);
+}
+
+.login-modal {
+ background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
+ border: 1px solid #444;
+ border-radius: 16px;
+ padding: 40px;
+ max-width: 450px;
+ width: 90%;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
+}
+
+.login-mode-selector {
+ display: flex;
+ margin-bottom: 24px;
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 8px;
+ padding: 4px;
+}
+
+.mode-button {
+ flex: 1;
+ padding: 12px 16px;
+ border: none;
+ background: transparent;
+ color: #ccc;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ font-weight: 500;
+}
+
+.mode-button.active {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
+}
+
+.mode-button:hover:not(.active) {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+}
+
+.oauth-login {
+ text-align: center;
+}
+
+.oauth-info {
+ margin-bottom: 24px;
+ padding: 20px;
+ background: rgba(102, 126, 234, 0.1);
+ border-radius: 12px;
+ border: 1px solid rgba(102, 126, 234, 0.3);
+}
+
+.oauth-info h3 {
+ margin: 0 0 12px 0;
+ font-size: 18px;
+ color: #667eea;
+}
+
+.oauth-info p {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.5;
+ opacity: 0.9;
+}
+
+.oauth-login-button {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border: none;
+ color: white;
+ padding: 16px 32px;
+ border-radius: 12px;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3);
+}
+
+.oauth-login-button:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
+}
+
+.oauth-login-button:disabled {
+ opacity: 0.7;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.login-modal h2 {
+ margin: 0 0 30px 0;
+ font-size: 28px;
+ text-align: center;
+ background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.form-group {
+ margin-bottom: 20px;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 8px;
+ color: #ccc;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.form-group input {
+ width: 100%;
+ padding: 12px 16px;
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid #444;
+ border-radius: 8px;
+ color: white;
+ font-size: 16px;
+ transition: all 0.3s ease;
+}
+
+.form-group input:focus {
+ outline: none;
+ border-color: #fff700;
+ background: rgba(255, 255, 255, 0.15);
+ box-shadow: 0 0 0 2px rgba(255, 247, 0, 0.2);
+}
+
+.form-group input:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.form-group small {
+ display: block;
+ margin-top: 6px;
+ color: #888;
+ font-size: 12px;
+}
+
+.form-group small a {
+ color: #fff700;
+ text-decoration: none;
+}
+
+.form-group small a:hover {
+ text-decoration: underline;
+}
+
+.error-message {
+ background: rgba(255, 71, 87, 0.1);
+ border: 1px solid rgba(255, 71, 87, 0.3);
+ border-radius: 8px;
+ padding: 12px;
+ margin-bottom: 20px;
+ color: #ff4757;
+ font-size: 14px;
+}
+
+.button-group {
+ display: flex;
+ gap: 12px;
+ margin-top: 30px;
+}
+
+.login-button,
+.cancel-button {
+ flex: 1;
+ padding: 14px 24px;
+ border: none;
+ border-radius: 8px;
+ font-size: 16px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.login-button {
+ background: linear-gradient(135deg, #fff700 0%, #ffd700 100%);
+ color: #000;
+}
+
+.login-button:hover:not(:disabled) {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(255, 247, 0, 0.4);
+}
+
+.login-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.cancel-button {
+ background: rgba(255, 255, 255, 0.1);
+ color: white;
+ border: 1px solid #444;
+}
+
+.cancel-button:hover:not(:disabled) {
+ background: rgba(255, 255, 255, 0.15);
+ border-color: #666;
+}
+
+.login-info {
+ margin-top: 30px;
+ padding-top: 20px;
+ border-top: 1px solid #333;
+ text-align: center;
+}
+
+.login-info p {
+ color: #888;
+ font-size: 14px;
+ line-height: 1.6;
+ margin: 0;
+}
+
+.dev-notice {
+ background: rgba(255, 193, 7, 0.1);
+ border: 1px solid rgba(255, 193, 7, 0.3);
+ border-radius: 6px;
+ padding: 8px 12px;
+ margin: 10px 0;
+ color: #ffc107;
+ font-size: 12px;
+ text-align: center;
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/src/types/card.ts b/aicard-web-oauth/src/types/card.ts
new file mode 100644
index 0000000..e9d7a77
--- /dev/null
+++ b/aicard-web-oauth/src/types/card.ts
@@ -0,0 +1,24 @@
+export enum CardRarity {
+ NORMAL = "normal",
+ RARE = "rare",
+ SUPER_RARE = "super_rare",
+ KIRA = "kira",
+ UNIQUE = "unique"
+}
+
+export interface Card {
+ id: number;
+ cp: number;
+ status: CardRarity;
+ skill?: string;
+ owner_did: string;
+ obtained_at: string;
+ is_unique: boolean;
+ unique_id?: string;
+}
+
+export interface CardDrawResult {
+ card: Card;
+ is_new: boolean;
+ animation_type: string;
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/src/utils/oauth-endpoints.ts b/aicard-web-oauth/src/utils/oauth-endpoints.ts
new file mode 100644
index 0000000..9e7ab1a
--- /dev/null
+++ b/aicard-web-oauth/src/utils/oauth-endpoints.ts
@@ -0,0 +1,141 @@
+/**
+ * OAuth dynamic endpoint handlers
+ */
+import { OAuthKeyManager, generateClientMetadata } from './oauth-keys';
+
+export class OAuthEndpointHandler {
+ /**
+ * Initialize OAuth endpoint handlers
+ */
+ static init() {
+ // Intercept requests to client-metadata.json
+ this.setupClientMetadataHandler();
+
+ // Intercept requests to .well-known/jwks.json
+ this.setupJWKSHandler();
+ }
+
+ private static setupClientMetadataHandler() {
+ // Override fetch for client-metadata.json requests
+ const originalFetch = window.fetch;
+
+ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === 'string' ? input : input.toString();
+
+ // Only intercept local OAuth endpoints
+ try {
+ const urlObj = new URL(url, window.location.origin);
+
+ // Only intercept requests to the same origin
+ if (urlObj.origin !== window.location.origin) {
+ // Pass through external API calls unchanged
+ return originalFetch(input, init);
+ }
+
+ // Handle local OAuth endpoints
+ if (urlObj.pathname.endsWith('/client-metadata.json')) {
+ const metadata = generateClientMetadata();
+ return new Response(JSON.stringify(metadata, null, 2), {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Access-Control-Allow-Origin': '*'
+ }
+ });
+ }
+
+ if (urlObj.pathname.endsWith('/.well-known/jwks.json')) {
+ try {
+ const jwks = await OAuthKeyManager.getJWKS();
+ return new Response(JSON.stringify(jwks, null, 2), {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Access-Control-Allow-Origin': '*'
+ }
+ });
+ } catch (error) {
+ console.error('Failed to generate JWKS:', error);
+ return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' }
+ });
+ }
+ }
+ } catch (e) {
+ // If URL parsing fails, pass through to original fetch
+ console.debug('URL parsing failed, passing through:', e);
+ }
+
+ // Pass through all other requests
+ return originalFetch(input, init);
+ };
+ }
+
+ private static setupJWKSHandler() {
+ // This is handled in the fetch override above
+ }
+
+ /**
+ * Generate a proper client assertion JWT for token requests
+ */
+ static async generateClientAssertion(tokenEndpoint: string): Promise {
+ const now = Math.floor(Date.now() / 1000);
+ const clientId = generateClientMetadata().client_id;
+
+ const header = {
+ alg: 'ES256',
+ typ: 'JWT',
+ kid: 'ai-card-oauth-key-1'
+ };
+
+ const payload = {
+ iss: clientId,
+ sub: clientId,
+ aud: tokenEndpoint,
+ iat: now,
+ exp: now + 300, // 5 minutes
+ jti: crypto.randomUUID()
+ };
+
+ return await OAuthKeyManager.signJWT(header, payload);
+ }
+}
+
+/**
+ * Service Worker alternative for intercepting requests
+ * (This is a more robust solution for production)
+ */
+export function registerOAuthServiceWorker() {
+ if ('serviceWorker' in navigator) {
+ const swCode = `
+ self.addEventListener('fetch', (event) => {
+ const url = new URL(event.request.url);
+
+ if (url.pathname.endsWith('/client-metadata.json')) {
+ event.respondWith(
+ new Response(JSON.stringify({
+ client_id: url.origin + '/client-metadata.json',
+ client_name: 'ai.card',
+ client_uri: url.origin,
+ redirect_uris: [url.origin + '/oauth/callback'],
+ response_types: ['code'],
+ grant_types: ['authorization_code', 'refresh_token'],
+ token_endpoint_auth_method: 'private_key_jwt',
+ scope: 'atproto transition:generic',
+ subject_type: 'public',
+ application_type: 'web',
+ dpop_bound_access_tokens: true,
+ jwks_uri: url.origin + '/.well-known/jwks.json'
+ }, null, 2), {
+ headers: { 'Content-Type': 'application/json' }
+ })
+ );
+ }
+ });
+ `;
+
+ const blob = new Blob([swCode], { type: 'application/javascript' });
+ const swUrl = URL.createObjectURL(blob);
+
+ navigator.serviceWorker.register(swUrl).catch(console.error);
+ }
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/src/utils/oauth-keys.ts b/aicard-web-oauth/src/utils/oauth-keys.ts
new file mode 100644
index 0000000..85282d2
--- /dev/null
+++ b/aicard-web-oauth/src/utils/oauth-keys.ts
@@ -0,0 +1,204 @@
+/**
+ * OAuth JWKS key generation and management
+ */
+
+export interface JWK {
+ kty: string;
+ crv: string;
+ x: string;
+ y: string;
+ d?: string;
+ use: string;
+ kid: string;
+ alg: string;
+}
+
+export interface JWKS {
+ keys: JWK[];
+}
+
+export class OAuthKeyManager {
+ private static keyPair: CryptoKeyPair | null = null;
+ private static jwks: JWKS | null = null;
+
+ /**
+ * Generate or retrieve existing ECDSA key pair for OAuth
+ */
+ static async getKeyPair(): Promise {
+ if (this.keyPair) {
+ return this.keyPair;
+ }
+
+ // Try to load from localStorage first
+ const storedKey = localStorage.getItem('oauth_private_key');
+ if (storedKey) {
+ try {
+ const keyData = JSON.parse(storedKey);
+ this.keyPair = await this.importKeyPair(keyData);
+ return this.keyPair;
+ } catch (error) {
+ console.warn('Failed to load stored key, generating new one:', error);
+ localStorage.removeItem('oauth_private_key');
+ }
+ }
+
+ // Generate new key pair
+ this.keyPair = await window.crypto.subtle.generateKey(
+ {
+ name: 'ECDSA',
+ namedCurve: 'P-256',
+ },
+ true, // extractable
+ ['sign', 'verify']
+ );
+
+ // Store private key for persistence
+ await this.storeKeyPair(this.keyPair);
+
+ return this.keyPair;
+ }
+
+ /**
+ * Get JWKS (JSON Web Key Set) for public key distribution
+ */
+ static async getJWKS(): Promise {
+ if (this.jwks) {
+ return this.jwks;
+ }
+
+ const keyPair = await this.getKeyPair();
+ const publicKey = await window.crypto.subtle.exportKey('jwk', keyPair.publicKey);
+
+ this.jwks = {
+ keys: [
+ {
+ kty: publicKey.kty!,
+ crv: publicKey.crv!,
+ x: publicKey.x!,
+ y: publicKey.y!,
+ use: 'sig',
+ kid: 'ai-card-oauth-key-1',
+ alg: 'ES256'
+ }
+ ]
+ };
+
+ return this.jwks;
+ }
+
+ /**
+ * Sign a JWT with the private key
+ */
+ static async signJWT(header: any, payload: any): Promise {
+ const keyPair = await this.getKeyPair();
+
+ const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, '');
+ const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, '');
+ const message = `${headerB64}.${payloadB64}`;
+
+ const signature = await window.crypto.subtle.sign(
+ { name: 'ECDSA', hash: 'SHA-256' },
+ keyPair.privateKey,
+ new TextEncoder().encode(message)
+ );
+
+ const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '');
+
+ return `${message}.${signatureB64}`;
+ }
+
+ private static async storeKeyPair(keyPair: CryptoKeyPair): Promise {
+ try {
+ const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
+ localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
+ } catch (error) {
+ console.error('Failed to store private key:', error);
+ }
+ }
+
+ private static async importKeyPair(keyData: any): Promise {
+ const privateKey = await window.crypto.subtle.importKey(
+ 'jwk',
+ keyData,
+ { name: 'ECDSA', namedCurve: 'P-256' },
+ true,
+ ['sign']
+ );
+
+ // Derive public key from private key
+ const publicKeyData = { ...keyData };
+ delete publicKeyData.d; // Remove private component
+
+ const publicKey = await window.crypto.subtle.importKey(
+ 'jwk',
+ publicKeyData,
+ { name: 'ECDSA', namedCurve: 'P-256' },
+ true,
+ ['verify']
+ );
+
+ return { privateKey, publicKey };
+ }
+
+ /**
+ * Clear stored keys (for testing/reset)
+ */
+ static clearKeys(): void {
+ localStorage.removeItem('oauth_private_key');
+ this.keyPair = null;
+ this.jwks = null;
+ }
+}
+
+/**
+ * Generate dynamic client metadata based on current URL
+ */
+export function generateClientMetadata(): any {
+ const origin = window.location.origin;
+ const clientId = `${origin}/client-metadata.json`;
+
+ // Use static production metadata for xxxcard.syui.ai
+ if (origin === 'https://xxxcard.syui.ai') {
+ return {
+ client_id: 'https://xxxcard.syui.ai/client-metadata.json',
+ client_name: 'ai.card',
+ client_uri: 'https://xxxcard.syui.ai',
+ logo_uri: 'https://xxxcard.syui.ai/favicon.ico',
+ tos_uri: 'https://xxxcard.syui.ai/terms',
+ policy_uri: 'https://xxxcard.syui.ai/privacy',
+ redirect_uris: ['https://xxxcard.syui.ai/oauth/callback'],
+ response_types: ['code'],
+ grant_types: ['authorization_code', 'refresh_token'],
+ token_endpoint_auth_method: 'private_key_jwt',
+ token_endpoint_auth_signing_alg: 'ES256',
+ scope: 'atproto transition:generic',
+ subject_type: 'public',
+ application_type: 'web',
+ dpop_bound_access_tokens: true,
+ jwks_uri: 'https://xxxcard.syui.ai/.well-known/jwks.json'
+ };
+ }
+
+ // Dynamic metadata for development
+ return {
+ client_id: clientId,
+ client_name: 'ai.card',
+ client_uri: origin,
+ logo_uri: `${origin}/favicon.ico`,
+ tos_uri: `${origin}/terms`,
+ policy_uri: `${origin}/privacy`,
+ redirect_uris: [`${origin}/oauth/callback`],
+ response_types: ['code'],
+ grant_types: ['authorization_code', 'refresh_token'],
+ token_endpoint_auth_method: 'private_key_jwt',
+ token_endpoint_auth_signing_alg: 'ES256',
+ scope: 'atproto transition:generic',
+ subject_type: 'public',
+ application_type: 'web',
+ dpop_bound_access_tokens: true,
+ jwks_uri: `${origin}/.well-known/jwks.json`
+ };
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/tsconfig.json b/aicard-web-oauth/tsconfig.json
new file mode 100644
index 0000000..d0104ed
--- /dev/null
+++ b/aicard-web-oauth/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/tsconfig.node.json b/aicard-web-oauth/tsconfig.node.json
new file mode 100644
index 0000000..4eb43d0
--- /dev/null
+++ b/aicard-web-oauth/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
\ No newline at end of file
diff --git a/aicard-web-oauth/vite.config.ts b/aicard-web-oauth/vite.config.ts
new file mode 100644
index 0000000..0fb8cad
--- /dev/null
+++ b/aicard-web-oauth/vite.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ // Keep console.log in production for debugging
+ minify: 'esbuild',
+ },
+ esbuild: {
+ drop: [], // Don't drop console.log
+ },
+ server: {
+ port: 5173,
+ host: '127.0.0.1',
+ allowedHosts: ['localhost', '127.0.0.1', 'xxxcard.syui.ai'],
+ proxy: {
+ '/api': {
+ target: 'http://127.0.0.1:8000',
+ changeOrigin: true,
+ secure: false,
+ }
+ },
+ // Handle OAuth callback routing
+ historyApiFallback: {
+ rewrites: [
+ { from: /^\/oauth\/callback/, to: '/index.html' }
+ ]
+ }
+ }
+})
\ No newline at end of file
diff --git a/mcp_integration.md b/mcp_integration.md
deleted file mode 100644
index 2ae67d8..0000000
--- a/mcp_integration.md
+++ /dev/null
@@ -1,164 +0,0 @@
-# ai.log MCP Integration Guide
-
-ai.logをai.gptと連携するためのMCPサーバー設定ガイド
-
-## MCPサーバー起動
-
-```bash
-# ai.logプロジェクトディレクトリで
-./target/debug/ailog mcp --port 8002
-
-# またはサブディレクトリから
-./target/debug/ailog mcp --port 8002 --path /path/to/blog
-```
-
-## ai.gptでの設定
-
-ai.logツールはai.gptのMCPサーバーに統合済みです。`config.json`に以下の設定が含まれています:
-
-```json
-{
- "mcp": {
- "enabled": "true",
- "auto_detect": "true",
- "servers": {
- "ai_gpt": {
- "base_url": "http://localhost:8001",
- "endpoints": {
- "log_create_post": "/log_create_post",
- "log_list_posts": "/log_list_posts",
- "log_build_blog": "/log_build_blog",
- "log_get_post": "/log_get_post",
- "log_system_status": "/log_system_status",
- "log_ai_content": "/log_ai_content"
- }
- }
- }
- }
-}
-```
-
-**重要**: ai.logツールを使用するには、ai.logディレクトリが `./log/` に存在し、ai.logのMCPサーバーがポート8002で稼働している必要があります。
-
-## 利用可能なMCPツール(ai.gpt統合版)
-
-### 1. log_create_post
-新しいブログ記事を作成します。
-
-**パラメータ**:
-- `title` (必須): 記事のタイトル
-- `content` (必須): Markdown形式の記事内容
-- `tags` (オプション): 記事のタグ配列
-- `slug` (オプション): カスタムURL slug
-
-**使用例**:
-```python
-# Claude Code/ai.gptから自動呼び出し
-# "ブログ記事を書いて"という発言で自動トリガー
-```
-
-### 2. log_list_posts
-既存のブログ記事一覧を取得します。
-
-**パラメータ**:
-- `limit` (オプション): 取得件数上限 (デフォルト: 10)
-- `offset` (オプション): スキップ件数 (デフォルト: 0)
-
-### 3. log_build_blog
-ブログをビルドして静的ファイルを生成します。
-
-**パラメータ**:
-- `enable_ai` (オプション): AI機能を有効化 (デフォルト: true)
-- `translate` (オプション): 自動翻訳を有効化 (デフォルト: false)
-
-### 4. log_get_post
-指定したスラッグの記事内容を取得します。
-
-**パラメータ**:
-- `slug` (必須): 記事のスラッグ
-
-### 5. log_system_status
-ai.logシステムの状態を確認します。
-
-### 6. log_ai_content ⭐ NEW
-AI記憶システムと連携して自動でブログ記事を生成・投稿します。
-
-**パラメータ**:
-- `user_id` (必須): ユーザーID
-- `topic` (オプション): 記事のトピック (デフォルト: "daily thoughts")
-
-**機能**:
-- ai.gptの記憶システムから関連する思い出を取得
-- AI技術で記憶をブログ記事に変換
-- 自動でai.logに投稿
-
-## ai.gptからの連携パターン
-
-### 記事の自動投稿
-```python
-# 記憶システムから関連情報を取得
-memories = get_contextual_memories("ブログ")
-
-# AI記事生成
-content = generate_blog_content(memories)
-
-# ai.logに投稿
-result = await mcp_client.call_tool("create_blog_post", {
- "title": "今日の思考メモ",
- "content": content,
- "tags": ["日記", "AI"]
-})
-
-# ビルド実行
-await mcp_client.call_tool("build_blog", {"enable_ai": True})
-```
-
-### 記事一覧の確認と編集
-```python
-# 記事一覧取得
-posts = await mcp_client.call_tool("list_blog_posts", {"limit": 5})
-
-# 特定記事の内容取得
-content = await mcp_client.call_tool("get_post_content", {
- "slug": "ai-integration"
-})
-
-# 修正版を投稿(上書き)
-updated_content = enhance_content(content)
-await mcp_client.call_tool("create_blog_post", {
- "title": "AI統合の新しい可能性(改訂版)",
- "content": updated_content,
- "slug": "ai-integration-revised"
-})
-```
-
-## 自動化ワークフロー
-
-ai.gptのスケジューラーと組み合わせて:
-
-1. **日次ブログ投稿**: 蓄積された記憶から記事を自動生成・投稿
-2. **記事修正**: 既存記事の内容を自動改善
-3. **関連記事提案**: 過去記事との関連性に基づく新記事提案
-4. **多言語対応**: 自動翻訳によるグローバル展開
-
-## エラーハンドリング
-
-MCPツール呼び出し時のエラーは以下の形式で返されます:
-
-```json
-{
- "jsonrpc": "2.0",
- "id": "request_id",
- "error": {
- "code": -32000,
- "message": "エラーメッセージ",
- "data": null
- }
-}
-```
-
-## セキュリティ考慮事項
-
-- MCPサーバーはローカルホストでのみ動作
-- ai.gptからの認証済みリクエストのみ処理
-- ファイルアクセスは指定されたブログディレクトリ内に制限
\ No newline at end of file
diff --git a/run.zsh b/run.zsh
new file mode 100755
index 0000000..580bff7
--- /dev/null
+++ b/run.zsh
@@ -0,0 +1,55 @@
+#!/bin/zsh
+
+# Simple build script for ai.log
+# Usage: ./run.zsh [serve|build|oauth|clean|tunnel|all]
+
+function _env() {
+ d=${0:a:h}
+ ailog=$d/target/release/ailog
+}
+
+function _server() {
+ _env
+ lsof -ti:8080 | xargs kill -9 2>/dev/null || true
+ cd $d/my-blog
+ $ailog build --release
+ $ailog serve --port 8080
+}
+
+function _server_public() {
+ _env
+ cloudflared tunnel --config $d/aicard-web-oauth/cloudflared-config.yml run
+}
+
+function _oauth_build() {
+ _env
+ cd $d/aicard-web-oauth
+ export NVM_DIR="$HOME/.nvm"
+ [ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh" # This loads nvm
+ [ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" # This loads nvm bash_completion
+ nvm use 21
+ npm i
+ npm run build
+ npm run preview
+}
+
+function _server_comment() {
+ _env
+ cargo build --release
+ AILOG_DEBUG_ALL=1 $ailog stream start
+}
+
+case "${1:-serve}" in
+ tunnel|c)
+ _server_public
+ ;;
+ oauth|o)
+ _oauth_build
+ ;;
+ comment|co)
+ _server_comment
+ ;;
+ serve|s|*)
+ _server
+ ;;
+esac
diff --git a/src/commands/auth.rs b/src/commands/auth.rs
new file mode 100644
index 0000000..098e82d
--- /dev/null
+++ b/src/commands/auth.rs
@@ -0,0 +1,293 @@
+use anyhow::{Result, Context};
+use colored::Colorize;
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::path::PathBuf;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AuthConfig {
+ pub admin: AdminConfig,
+ pub jetstream: JetstreamConfig,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AdminConfig {
+ pub did: String,
+ pub handle: String,
+ pub access_jwt: String,
+ pub refresh_jwt: String,
+ pub pds: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct JetstreamConfig {
+ pub url: String,
+ pub collections: Vec,
+}
+
+impl Default for AuthConfig {
+ fn default() -> Self {
+ Self {
+ admin: AdminConfig {
+ did: String::new(),
+ handle: String::new(),
+ access_jwt: String::new(),
+ refresh_jwt: String::new(),
+ pds: "https://bsky.social".to_string(),
+ },
+ jetstream: JetstreamConfig {
+ url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(),
+ collections: vec!["ai.syui.log".to_string()],
+ },
+ }
+ }
+}
+
+fn get_config_path() -> Result {
+ let home = std::env::var("HOME").context("HOME environment variable not set")?;
+ let config_dir = PathBuf::from(home).join(".config").join("syui").join("ai").join("log");
+
+ // Create directory if it doesn't exist
+ fs::create_dir_all(&config_dir)?;
+
+ Ok(config_dir.join("config.json"))
+}
+
+pub async fn init() -> Result<()> {
+ println!("{}", "🔐 Initializing ATProto authentication...".cyan());
+
+ let config_path = get_config_path()?;
+
+ if config_path.exists() {
+ println!("{}", "⚠️ Configuration already exists. Use 'ailog auth logout' to reset.".yellow());
+ return Ok(());
+ }
+
+ println!("{}", "📋 Please provide your ATProto credentials:".cyan());
+
+ // Get user input
+ print!("Handle (e.g., your.handle.bsky.social): ");
+ std::io::Write::flush(&mut std::io::stdout())?;
+ let mut handle = String::new();
+ std::io::stdin().read_line(&mut handle)?;
+ let handle = handle.trim().to_string();
+
+ print!("Access JWT: ");
+ std::io::Write::flush(&mut std::io::stdout())?;
+ let mut access_jwt = String::new();
+ std::io::stdin().read_line(&mut access_jwt)?;
+ let access_jwt = access_jwt.trim().to_string();
+
+ print!("Refresh JWT: ");
+ std::io::Write::flush(&mut std::io::stdout())?;
+ let mut refresh_jwt = String::new();
+ std::io::stdin().read_line(&mut refresh_jwt)?;
+ let refresh_jwt = refresh_jwt.trim().to_string();
+
+ // Resolve DID from handle
+ println!("{}", "🔍 Resolving DID from handle...".cyan());
+ let did = resolve_did(&handle).await?;
+
+ // Create config
+ let config = AuthConfig {
+ admin: AdminConfig {
+ did: did.clone(),
+ handle: handle.clone(),
+ access_jwt,
+ refresh_jwt,
+ pds: if handle.ends_with(".syu.is") {
+ "https://syu.is".to_string()
+ } else {
+ "https://bsky.social".to_string()
+ },
+ },
+ jetstream: JetstreamConfig {
+ url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(),
+ collections: vec!["ai.syui.log".to_string()],
+ },
+ };
+
+ // Save config
+ let config_json = serde_json::to_string_pretty(&config)?;
+ fs::write(&config_path, config_json)?;
+
+ println!("{}", "✅ Authentication configured successfully!".green());
+ println!("📁 Config saved to: {}", config_path.display());
+ println!("👤 Authenticated as: {} ({})", handle, did);
+
+ Ok(())
+}
+
+async fn resolve_did(handle: &str) -> Result {
+ let client = reqwest::Client::new();
+ let url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}",
+ urlencoding::encode(handle));
+
+ let response = client.get(&url).send().await?;
+
+ if !response.status().is_success() {
+ return Err(anyhow::anyhow!("Failed to resolve handle: {}", response.status()));
+ }
+
+ let profile: serde_json::Value = response.json().await?;
+ let did = profile["did"].as_str()
+ .ok_or_else(|| anyhow::anyhow!("DID not found in profile response"))?;
+
+ Ok(did.to_string())
+}
+
+pub async fn status() -> Result<()> {
+ let config_path = get_config_path()?;
+
+ if !config_path.exists() {
+ println!("{}", "❌ Not authenticated. Run 'ailog auth init' first.".red());
+ return Ok(());
+ }
+
+ let config_json = fs::read_to_string(&config_path)?;
+ let config: AuthConfig = serde_json::from_str(&config_json)?;
+
+ println!("{}", "🔐 Authentication Status".cyan().bold());
+ println!("─────────────────────────");
+ println!("📁 Config: {}", config_path.display());
+ println!("👤 Handle: {}", config.admin.handle.green());
+ println!("🆔 DID: {}", config.admin.did);
+ println!("🌐 PDS: {}", config.admin.pds);
+ println!("📡 Jetstream: {}", config.jetstream.url);
+ println!("📂 Collections: {}", config.jetstream.collections.join(", "));
+
+ // Test API access
+ println!("\n{}", "🧪 Testing API access...".cyan());
+ match test_api_access(&config).await {
+ Ok(_) => println!("{}", "✅ API access successful".green()),
+ Err(e) => println!("{}", format!("❌ API access failed: {}", e).red()),
+ }
+
+ Ok(())
+}
+
+async fn test_api_access(config: &AuthConfig) -> Result<()> {
+ let client = reqwest::Client::new();
+ let url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}",
+ urlencoding::encode(&config.admin.handle));
+
+ let response = client.get(&url).send().await?;
+
+ if !response.status().is_success() {
+ return Err(anyhow::anyhow!("API request failed: {}", response.status()));
+ }
+
+ Ok(())
+}
+
+pub async fn logout() -> Result<()> {
+ let config_path = get_config_path()?;
+
+ if !config_path.exists() {
+ println!("{}", "ℹ️ Already logged out.".blue());
+ return Ok(());
+ }
+
+ println!("{}", "🔓 Logging out...".cyan());
+
+ // Remove config file
+ fs::remove_file(&config_path)?;
+
+ println!("{}", "✅ Logged out successfully!".green());
+ println!("🗑️ Configuration removed from: {}", config_path.display());
+
+ Ok(())
+}
+
+// Load config helper function for other modules
+pub fn load_config() -> Result {
+ let config_path = get_config_path()?;
+
+ if !config_path.exists() {
+ return Err(anyhow::anyhow!("Not authenticated. Run 'ailog auth init' first."));
+ }
+
+ let config_json = fs::read_to_string(&config_path)?;
+ let config: AuthConfig = serde_json::from_str(&config_json)?;
+
+ Ok(config)
+}
+
+// Load config with automatic token refresh
+pub async fn load_config_with_refresh() -> Result {
+ let mut config = load_config()?;
+
+ // Test if current access token is still valid
+ if let Err(_) = test_api_access_with_auth(&config).await {
+ println!("{}", "🔄 Access token expired, refreshing...".yellow());
+
+ // Try to refresh the token
+ match refresh_access_token(&mut config).await {
+ Ok(_) => {
+ save_config(&config)?;
+ println!("{}", "✅ Token refreshed successfully".green());
+ }
+ Err(e) => {
+ return Err(anyhow::anyhow!("Failed to refresh token: {}. Please run 'ailog auth init' again.", e));
+ }
+ }
+ }
+
+ Ok(config)
+}
+
+async fn test_api_access_with_auth(config: &AuthConfig) -> Result<()> {
+ let client = reqwest::Client::new();
+ let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection=ai.syui.log&limit=1",
+ config.admin.pds,
+ urlencoding::encode(&config.admin.did));
+
+ let response = client
+ .get(&url)
+ .header("Authorization", format!("Bearer {}", config.admin.access_jwt))
+ .send()
+ .await?;
+
+ if !response.status().is_success() {
+ return Err(anyhow::anyhow!("API request failed: {}", response.status()));
+ }
+
+ Ok(())
+}
+
+async fn refresh_access_token(config: &mut AuthConfig) -> Result<()> {
+ let client = reqwest::Client::new();
+ let url = format!("{}/xrpc/com.atproto.server.refreshSession", config.admin.pds);
+
+ let response = client
+ .post(&url)
+ .header("Authorization", format!("Bearer {}", config.admin.refresh_jwt))
+ .send()
+ .await?;
+
+ if !response.status().is_success() {
+ let status = response.status();
+ let error_text = response.text().await?;
+ return Err(anyhow::anyhow!("Token refresh failed: {} - {}", status, error_text));
+ }
+
+ let refresh_response: serde_json::Value = response.json().await?;
+
+ // Update tokens
+ if let Some(access_jwt) = refresh_response["accessJwt"].as_str() {
+ config.admin.access_jwt = access_jwt.to_string();
+ }
+
+ if let Some(refresh_jwt) = refresh_response["refreshJwt"].as_str() {
+ config.admin.refresh_jwt = refresh_jwt.to_string();
+ }
+
+ Ok(())
+}
+
+fn save_config(config: &AuthConfig) -> Result<()> {
+ let config_path = get_config_path()?;
+ let config_json = serde_json::to_string_pretty(config)?;
+ fs::write(&config_path, config_json)?;
+ Ok(())
+}
\ No newline at end of file
diff --git a/src/commands/init.rs b/src/commands/init.rs
index 57fcb03..d1229b6 100644
--- a/src/commands/init.rs
+++ b/src/commands/init.rs
@@ -44,7 +44,7 @@ comment_moderation = false
fs::write(path.join("config.toml"), config_content)?;
println!(" {} config.toml", "Created".cyan());
- // Create default template
+ // Create modern template
let base_template = r#"
@@ -54,18 +54,83 @@ comment_moderation = false
-
-
- {{ config.description }}
-
+
+
+
+
+
+
Hi! 👋
+
I'm an AI assistant trained on this blog's content.
+
Ask me anything about the articles here.
+
+
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+ {% block sidebar %}{% endblock %}
+
-
- {% block content %}{% endblock %}
-
-
-