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 + +[![Rust](https://img.shields.io/badge/Rust-000000?style=for-the-badge&logo=rust&logoColor=white)](https://www.rust-lang.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 Avatar +
+

{user.displayName || user.handle}

+

@{user.handle}

+

DID: {user.did}

+
+
+ +
+ + {/* Admin Section - User Management */} + {isAdmin(user) && ( +
+

管理者機能 - ユーザーリスト管理

+ + {/* User List Form */} +
+