oauth_new
This commit is contained in:
		@@ -52,7 +52,8 @@
 | 
			
		||||
      "WebFetch(domain:plc.directory)",
 | 
			
		||||
      "WebFetch(domain:atproto.com)",
 | 
			
		||||
      "WebFetch(domain:syu.is)",
 | 
			
		||||
      "Bash(sed:*)"
 | 
			
		||||
      "Bash(sed:*)",
 | 
			
		||||
      "Bash(./scpt/run.zsh:*)"
 | 
			
		||||
    ],
 | 
			
		||||
    "deny": []
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,6 @@ VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "ai.syui.ai", "ai.ai"]
 | 
			
		||||
VITE_AI_ENABLED=true
 | 
			
		||||
VITE_AI_ASK_AI=true
 | 
			
		||||
VITE_AI_PROVIDER=ollama
 | 
			
		||||
VITE_AI_MODEL=gemma3:4b
 | 
			
		||||
VITE_AI_MODEL=qwen3
 | 
			
		||||
VITE_AI_HOST=http://localhost:11434
 | 
			
		||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										116
									
								
								oauth_new/ASK_AI_INTEGRATION.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								oauth_new/ASK_AI_INTEGRATION.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,116 @@
 | 
			
		||||
# Ask-AI Integration Implementation
 | 
			
		||||
 | 
			
		||||
## 概要
 | 
			
		||||
 | 
			
		||||
oauth_new アプリに ask-AI 機能を統合しました。この機能により、ユーザーはAIと対話し、その結果を atproto に記録できます。
 | 
			
		||||
 | 
			
		||||
## 実装されたファイル
 | 
			
		||||
 | 
			
		||||
### 1. `/src/hooks/useAskAI.js`
 | 
			
		||||
- ask-AI サーバーとの通信機能
 | 
			
		||||
- atproto への putRecord 機能
 | 
			
		||||
- チャット履歴の管理
 | 
			
		||||
- イベント送信(blog との通信用)
 | 
			
		||||
 | 
			
		||||
### 2. `/src/components/AskAI.jsx`
 | 
			
		||||
- チャット UI コンポーネント
 | 
			
		||||
- 質問入力・回答表示
 | 
			
		||||
- 認証チェック
 | 
			
		||||
- IME 対応
 | 
			
		||||
 | 
			
		||||
### 3. `/src/App.jsx` の更新
 | 
			
		||||
- AskAI コンポーネントの統合
 | 
			
		||||
- Ask AI ボタンの追加
 | 
			
		||||
- イベントリスナーの設定
 | 
			
		||||
- blog との通信機能
 | 
			
		||||
 | 
			
		||||
## JSON 構造の記録
 | 
			
		||||
 | 
			
		||||
`./json/` ディレクトリに各 collection の構造を記録しました:
 | 
			
		||||
 | 
			
		||||
- `ai.syui.ai_user.json` - ユーザーリスト
 | 
			
		||||
- `ai.syui.ai_chat.json` - チャット記録(空)
 | 
			
		||||
- `syui.syui.ai_chat.json` - チャット記録(実データ)
 | 
			
		||||
- `ai.syui.ai_chat_lang.json` - 翻訳記録
 | 
			
		||||
- `ai.syui.ai_chat_comment.json` - コメント記録
 | 
			
		||||
 | 
			
		||||
## 実際の ai.syui.log.chat 構造
 | 
			
		||||
 | 
			
		||||
確認された実際の構造:
 | 
			
		||||
 | 
			
		||||
```json
 | 
			
		||||
{
 | 
			
		||||
  "$type": "ai.syui.log.chat",
 | 
			
		||||
  "post": {
 | 
			
		||||
    "url": "https://syui.ai/",
 | 
			
		||||
    "date": "2025-06-18T02:16:04.609Z",
 | 
			
		||||
    "slug": "",
 | 
			
		||||
    "tags": [],
 | 
			
		||||
    "title": "syui.ai",
 | 
			
		||||
    "language": "ja"
 | 
			
		||||
  },
 | 
			
		||||
  "text": "質問またはAI回答テキスト",
 | 
			
		||||
  "type": "question|answer",
 | 
			
		||||
  "author": {
 | 
			
		||||
    "did": "did:plc:...",
 | 
			
		||||
    "handle": "handle名",
 | 
			
		||||
    "displayName": "表示名",
 | 
			
		||||
    "avatar": "アバターURL"
 | 
			
		||||
  },
 | 
			
		||||
  "createdAt": "2025-06-18T02:16:04.609Z"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## イベント通信
 | 
			
		||||
 | 
			
		||||
blog(ask-ai.js)と OAuth アプリ間の通信:
 | 
			
		||||
 | 
			
		||||
### 送信イベント
 | 
			
		||||
- `postAIQuestion` - blog から OAuth アプリへ質問送信
 | 
			
		||||
- `aiProfileLoaded` - OAuth アプリから blog へ AI プロフィール送信
 | 
			
		||||
- `aiResponseReceived` - OAuth アプリから blog へ AI 回答送信
 | 
			
		||||
 | 
			
		||||
### 受信イベント
 | 
			
		||||
- OAuth アプリが `postAIQuestion` を受信して処理
 | 
			
		||||
- blog が `aiResponseReceived` を受信して表示
 | 
			
		||||
 | 
			
		||||
## 環境変数
 | 
			
		||||
 | 
			
		||||
```env
 | 
			
		||||
VITE_ASK_AI_URL=http://localhost:3000/ask  # ask-AI サーバーURL(デフォルト)
 | 
			
		||||
VITE_ADMIN_HANDLE=ai.syui.ai
 | 
			
		||||
VITE_ATPROTO_PDS=syu.is
 | 
			
		||||
VITE_OAUTH_COLLECTION=ai.syui.log
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## 機能
 | 
			
		||||
 | 
			
		||||
### 実装済み
 | 
			
		||||
- ✅ ask-AI サーバーとの通信
 | 
			
		||||
- ✅ atproto への question/answer record 保存
 | 
			
		||||
- ✅ チャット履歴の表示・管理
 | 
			
		||||
- ✅ blog との双方向イベント通信
 | 
			
		||||
- ✅ 認証機能(ログイン必須)
 | 
			
		||||
- ✅ エラーハンドリング・ローディング状態
 | 
			
		||||
- ✅ 実際の JSON 構造に合わせた実装
 | 
			
		||||
 | 
			
		||||
### 今後のテスト項目
 | 
			
		||||
- ask-AI サーバーの準備・起動
 | 
			
		||||
- 実際の質問送信テスト
 | 
			
		||||
- atproto への putRecord 動作確認
 | 
			
		||||
- blog からの連携テスト
 | 
			
		||||
 | 
			
		||||
## 使用方法
 | 
			
		||||
 | 
			
		||||
1. 開発サーバー起動: `npm run dev`
 | 
			
		||||
2. OAuth ログイン実行
 | 
			
		||||
3. "Ask AI" ボタンをクリック
 | 
			
		||||
4. チャット画面で質問入力
 | 
			
		||||
5. AI の回答が表示され、atproto に記録される
 | 
			
		||||
 | 
			
		||||
## 注意事項
 | 
			
		||||
 | 
			
		||||
- ask-AI サーバー(VITE_ASK_AI_URL)が必要
 | 
			
		||||
- 認証されたユーザーのみ質問可能
 | 
			
		||||
- ai.syui.log.chat への書き込み権限が必要
 | 
			
		||||
- Production 環境では logger が無効化される
 | 
			
		||||
							
								
								
									
										174
									
								
								oauth_new/AVATAR_SYSTEM.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								oauth_new/AVATAR_SYSTEM.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,174 @@
 | 
			
		||||
# Avatar Fetching System
 | 
			
		||||
 | 
			
		||||
This document describes the avatar fetching system implemented for the oauth_new application.
 | 
			
		||||
 | 
			
		||||
## Overview
 | 
			
		||||
 | 
			
		||||
The avatar system provides intelligent avatar fetching with fallback mechanisms, caching, and error handling. It follows the design specified in the project instructions:
 | 
			
		||||
 | 
			
		||||
1. **Primary Source**: Try to use avatar from record JSON first
 | 
			
		||||
2. **Fallback**: If avatar is broken/missing, fetch fresh data from ATProto
 | 
			
		||||
3. **Fresh Data Flow**: handle → PDS → DID → profile → avatar URI
 | 
			
		||||
4. **Caching**: Avoid excessive API calls with intelligent caching
 | 
			
		||||
 | 
			
		||||
## Files Structure
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
src/
 | 
			
		||||
├── utils/
 | 
			
		||||
│   └── avatar.js          # Core avatar fetching logic
 | 
			
		||||
├── components/
 | 
			
		||||
│   ├── Avatar.jsx         # React avatar component
 | 
			
		||||
│   └── AvatarTest.jsx     # Test component
 | 
			
		||||
└── App.css               # Avatar styling
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Core Functions
 | 
			
		||||
 | 
			
		||||
### `getAvatar(options)`
 | 
			
		||||
Main function to fetch avatar with intelligent fallback.
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
const avatar = await getAvatar({
 | 
			
		||||
  record: recordObject,     // Optional: record containing avatar data
 | 
			
		||||
  handle: 'user.handle',    // Required if no record
 | 
			
		||||
  did: 'did:plc:xxx',      // Optional: user DID
 | 
			
		||||
  forceFresh: false        // Optional: force fresh fetch
 | 
			
		||||
})
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### `batchFetchAvatars(users)`
 | 
			
		||||
Fetch avatars for multiple users in parallel with concurrency control.
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
const avatarMap = await batchFetchAvatars([
 | 
			
		||||
  { handle: 'user1.handle', did: 'did:plc:xxx1' },
 | 
			
		||||
  { handle: 'user2.handle', did: 'did:plc:xxx2' }
 | 
			
		||||
])
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### `prefetchAvatar(handle)`
 | 
			
		||||
Prefetch and cache avatar for a specific handle.
 | 
			
		||||
 | 
			
		||||
```javascript
 | 
			
		||||
await prefetchAvatar('user.handle')
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## React Components
 | 
			
		||||
 | 
			
		||||
### `<Avatar>`
 | 
			
		||||
Basic avatar component with loading states and fallbacks.
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<Avatar
 | 
			
		||||
  record={record}
 | 
			
		||||
  handle="user.handle"
 | 
			
		||||
  did="did:plc:xxx"
 | 
			
		||||
  size={40}
 | 
			
		||||
  showFallback={true}
 | 
			
		||||
  onLoad={() => console.log('loaded')}
 | 
			
		||||
  onError={(err) => console.log('error', err)}
 | 
			
		||||
/>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### `<AvatarWithCard>`
 | 
			
		||||
Avatar with hover card showing user information.
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<AvatarWithCard
 | 
			
		||||
  record={record}
 | 
			
		||||
  displayName="User Name"
 | 
			
		||||
  apiConfig={apiConfig}
 | 
			
		||||
  size={60}
 | 
			
		||||
/>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### `<AvatarList>`
 | 
			
		||||
Display multiple avatars with overlap effect.
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
<AvatarList 
 | 
			
		||||
  users={userArray}
 | 
			
		||||
  maxDisplay={5}
 | 
			
		||||
  size={30}
 | 
			
		||||
/>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Data Flow
 | 
			
		||||
 | 
			
		||||
1. **Record Check**: Extract avatar from record.value.author.avatar
 | 
			
		||||
2. **URL Validation**: Verify avatar URL is accessible (HEAD request)
 | 
			
		||||
3. **Fresh Fetch**: If broken, fetch fresh data:
 | 
			
		||||
   - Get PDS from handle using `getPdsFromHandle()`
 | 
			
		||||
   - Get API config using `getApiConfig()`
 | 
			
		||||
   - Get DID from PDS using `atproto.getDid()`
 | 
			
		||||
   - Get profile from bsky API using `atproto.getProfile()`
 | 
			
		||||
   - Extract avatar from profile
 | 
			
		||||
4. **Cache**: Store result in cache with 30-minute TTL
 | 
			
		||||
5. **Fallback**: Show initial-based fallback if no avatar found
 | 
			
		||||
 | 
			
		||||
## Caching Strategy
 | 
			
		||||
 | 
			
		||||
- **Cache Key**: `avatar:{handle}`
 | 
			
		||||
- **Duration**: 30 minutes (configurable)
 | 
			
		||||
- **Cache Provider**: Uses existing `dataCache` utility
 | 
			
		||||
- **Invalidation**: Manual cache clearing functions available
 | 
			
		||||
 | 
			
		||||
## Error Handling
 | 
			
		||||
 | 
			
		||||
- **Network Errors**: Gracefully handled with fallback UI
 | 
			
		||||
- **Broken URLs**: Automatically detected and re-fetched fresh
 | 
			
		||||
- **Missing Handles**: Throws descriptive error messages
 | 
			
		||||
- **API Failures**: Logged but don't break UI
 | 
			
		||||
 | 
			
		||||
## Integration
 | 
			
		||||
 | 
			
		||||
The avatar system is integrated into the existing RecordList component:
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
// Old approach
 | 
			
		||||
{record.value.author?.avatar && (
 | 
			
		||||
  <img src={record.value.author.avatar} alt="avatar" className="avatar" />
 | 
			
		||||
)}
 | 
			
		||||
 | 
			
		||||
// New approach
 | 
			
		||||
<Avatar
 | 
			
		||||
  record={record}
 | 
			
		||||
  handle={record.value.author?.handle}
 | 
			
		||||
  did={record.value.author?.did}
 | 
			
		||||
  size={40}
 | 
			
		||||
  showFallback={true}
 | 
			
		||||
/>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Testing
 | 
			
		||||
 | 
			
		||||
The system includes a comprehensive test component (`AvatarTest.jsx`) that can be accessed through the Test UI in the app. It demonstrates:
 | 
			
		||||
 | 
			
		||||
1. Avatar from record data
 | 
			
		||||
2. Avatar from handle only
 | 
			
		||||
3. Broken avatar URL handling
 | 
			
		||||
4. Batch fetching
 | 
			
		||||
5. Prefetch functionality
 | 
			
		||||
6. Various avatar components
 | 
			
		||||
 | 
			
		||||
To test:
 | 
			
		||||
1. Open the app
 | 
			
		||||
2. Click "Test" button in header
 | 
			
		||||
3. Switch to "Avatar System" tab
 | 
			
		||||
4. Use the test controls to verify functionality
 | 
			
		||||
 | 
			
		||||
## Performance Considerations
 | 
			
		||||
 | 
			
		||||
- **Concurrent Fetching**: Batch operations use concurrency limits (5 parallel requests)
 | 
			
		||||
- **Caching**: Reduces API calls by caching results
 | 
			
		||||
- **Lazy Loading**: Avatar images use lazy loading
 | 
			
		||||
- **Error Recovery**: Broken avatars are automatically retried with fresh data
 | 
			
		||||
 | 
			
		||||
## Future Enhancements
 | 
			
		||||
 | 
			
		||||
1. **Persistent Cache**: Consider localStorage for cross-session caching
 | 
			
		||||
2. **Image Optimization**: Add WebP support and size optimization
 | 
			
		||||
3. **Preloading**: Implement smarter preloading strategies
 | 
			
		||||
4. **CDN Integration**: Add CDN support for avatar delivery
 | 
			
		||||
5. **Placeholder Variations**: More diverse fallback avatar styles
 | 
			
		||||
							
								
								
									
										420
									
								
								oauth_new/AVATAR_USAGE_EXAMPLES.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										420
									
								
								oauth_new/AVATAR_USAGE_EXAMPLES.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,420 @@
 | 
			
		||||
# Avatar System Usage Examples
 | 
			
		||||
 | 
			
		||||
This document provides practical examples of how to use the avatar fetching system in your components.
 | 
			
		||||
 | 
			
		||||
## Basic Usage
 | 
			
		||||
 | 
			
		||||
### Simple Avatar Display
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
import Avatar from './components/Avatar.jsx'
 | 
			
		||||
 | 
			
		||||
function UserProfile({ user }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="user-profile">
 | 
			
		||||
      <Avatar
 | 
			
		||||
        handle={user.handle}
 | 
			
		||||
        did={user.did}
 | 
			
		||||
        size={80}
 | 
			
		||||
        alt={`${user.displayName}'s avatar`}
 | 
			
		||||
      />
 | 
			
		||||
      <h3>{user.displayName}</h3>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Avatar from Record Data
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
function CommentItem({ record }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="comment">
 | 
			
		||||
      <Avatar
 | 
			
		||||
        record={record}
 | 
			
		||||
        size={40}
 | 
			
		||||
        showFallback={true}
 | 
			
		||||
      />
 | 
			
		||||
      <div className="comment-content">
 | 
			
		||||
        <strong>{record.value.author.displayName}</strong>
 | 
			
		||||
        <p>{record.value.text}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Avatar with Hover Card
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
import { AvatarWithCard } from './components/Avatar.jsx'
 | 
			
		||||
 | 
			
		||||
function UserList({ users, apiConfig }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="user-list">
 | 
			
		||||
      {users.map(user => (
 | 
			
		||||
        <AvatarWithCard
 | 
			
		||||
          key={user.handle}
 | 
			
		||||
          handle={user.handle}
 | 
			
		||||
          did={user.did}
 | 
			
		||||
          displayName={user.displayName}
 | 
			
		||||
          apiConfig={apiConfig}
 | 
			
		||||
          size={50}
 | 
			
		||||
        />
 | 
			
		||||
      ))}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Advanced Usage
 | 
			
		||||
 | 
			
		||||
### Programmatic Avatar Fetching
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
import { useEffect, useState } from 'react'
 | 
			
		||||
import { getAvatar, batchFetchAvatars } from './utils/avatar.js'
 | 
			
		||||
 | 
			
		||||
function useUserAvatars(users) {
 | 
			
		||||
  const [avatars, setAvatars] = useState(new Map())
 | 
			
		||||
  const [loading, setLoading] = useState(false)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    async function fetchAvatars() {
 | 
			
		||||
      setLoading(true)
 | 
			
		||||
      try {
 | 
			
		||||
        const avatarMap = await batchFetchAvatars(users)
 | 
			
		||||
        setAvatars(avatarMap)
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('Failed to fetch avatars:', error)
 | 
			
		||||
      } finally {
 | 
			
		||||
        setLoading(false)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (users.length > 0) {
 | 
			
		||||
      fetchAvatars()
 | 
			
		||||
    }
 | 
			
		||||
  }, [users])
 | 
			
		||||
 | 
			
		||||
  return { avatars, loading }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Usage
 | 
			
		||||
function TeamDisplay({ team }) {
 | 
			
		||||
  const { avatars, loading } = useUserAvatars(team.members)
 | 
			
		||||
  
 | 
			
		||||
  if (loading) return <div>Loading team...</div>
 | 
			
		||||
  
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="team">
 | 
			
		||||
      {team.members.map(member => (
 | 
			
		||||
        <img 
 | 
			
		||||
          key={member.handle}
 | 
			
		||||
          src={avatars.get(member.handle) || '/default-avatar.png'}
 | 
			
		||||
          alt={member.displayName}
 | 
			
		||||
        />
 | 
			
		||||
      ))}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Force Refresh Avatar
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
import { useState } from 'react'
 | 
			
		||||
import Avatar from './components/Avatar.jsx'
 | 
			
		||||
import { getAvatar, clearAvatarCache } from './utils/avatar.js'
 | 
			
		||||
 | 
			
		||||
function RefreshableAvatar({ handle, did }) {
 | 
			
		||||
  const [key, setKey] = useState(0)
 | 
			
		||||
  
 | 
			
		||||
  const handleRefresh = async () => {
 | 
			
		||||
    // Clear cache for this user
 | 
			
		||||
    clearAvatarCache(handle)
 | 
			
		||||
    
 | 
			
		||||
    // Force re-render of Avatar component
 | 
			
		||||
    setKey(prev => prev + 1)
 | 
			
		||||
    
 | 
			
		||||
    // Optionally, prefetch fresh avatar
 | 
			
		||||
    try {
 | 
			
		||||
      await getAvatar({ handle, did, forceFresh: true })
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Failed to refresh avatar:', error)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="refreshable-avatar">
 | 
			
		||||
      <Avatar 
 | 
			
		||||
        key={key}
 | 
			
		||||
        handle={handle}
 | 
			
		||||
        did={did}
 | 
			
		||||
        size={60}
 | 
			
		||||
      />
 | 
			
		||||
      <button onClick={handleRefresh}>
 | 
			
		||||
        Refresh Avatar
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Avatar List with Overflow
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
import { AvatarList } from './components/Avatar.jsx'
 | 
			
		||||
 | 
			
		||||
function ParticipantsList({ participants, maxVisible = 5 }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="participants">
 | 
			
		||||
      <h4>Participants ({participants.length})</h4>
 | 
			
		||||
      <AvatarList 
 | 
			
		||||
        users={participants}
 | 
			
		||||
        maxDisplay={maxVisible}
 | 
			
		||||
        size={32}
 | 
			
		||||
      />
 | 
			
		||||
      {participants.length > maxVisible && (
 | 
			
		||||
        <span className="overflow-text">
 | 
			
		||||
          and {participants.length - maxVisible} more...
 | 
			
		||||
        </span>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Error Handling
 | 
			
		||||
 | 
			
		||||
### Custom Error Handling
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
import { useState } from 'react'
 | 
			
		||||
import Avatar from './components/Avatar.jsx'
 | 
			
		||||
 | 
			
		||||
function RobustAvatar({ handle, did, fallbackSrc }) {
 | 
			
		||||
  const [hasError, setHasError] = useState(false)
 | 
			
		||||
  
 | 
			
		||||
  const handleError = (error) => {
 | 
			
		||||
    console.warn(`Avatar failed for ${handle}:`, error)
 | 
			
		||||
    setHasError(true)
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (hasError && fallbackSrc) {
 | 
			
		||||
    return (
 | 
			
		||||
      <img 
 | 
			
		||||
        src={fallbackSrc}
 | 
			
		||||
        alt="Fallback avatar"
 | 
			
		||||
        className="avatar"
 | 
			
		||||
        onError={() => setHasError(false)} // Reset on fallback error
 | 
			
		||||
      />
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return (
 | 
			
		||||
    <Avatar
 | 
			
		||||
      handle={handle}
 | 
			
		||||
      did={did}
 | 
			
		||||
      onError={handleError}
 | 
			
		||||
      showFallback={!hasError}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Loading States
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
import { useState, useEffect } from 'react'
 | 
			
		||||
import { getAvatar } from './utils/avatar.js'
 | 
			
		||||
 | 
			
		||||
function AvatarWithCustomLoading({ handle, did }) {
 | 
			
		||||
  const [avatar, setAvatar] = useState(null)
 | 
			
		||||
  const [loading, setLoading] = useState(true)
 | 
			
		||||
  const [error, setError] = useState(null)
 | 
			
		||||
  
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    async function loadAvatar() {
 | 
			
		||||
      try {
 | 
			
		||||
        setLoading(true)
 | 
			
		||||
        setError(null)
 | 
			
		||||
        const avatarUrl = await getAvatar({ handle, did })
 | 
			
		||||
        setAvatar(avatarUrl)
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        setError(err.message)
 | 
			
		||||
      } finally {
 | 
			
		||||
        setLoading(false)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    loadAvatar()
 | 
			
		||||
  }, [handle, did])
 | 
			
		||||
  
 | 
			
		||||
  if (loading) {
 | 
			
		||||
    return <div className="avatar-loading-spinner">Loading...</div>
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (error) {
 | 
			
		||||
    return <div className="avatar-error">Failed to load avatar</div>
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (!avatar) {
 | 
			
		||||
    return <div className="avatar-placeholder">No avatar</div>
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return <img src={avatar} alt="Avatar" className="avatar" />
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Optimization Patterns
 | 
			
		||||
 | 
			
		||||
### Preloading Strategy
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
import { useEffect } from 'react'
 | 
			
		||||
import { prefetchAvatar } from './utils/avatar.js'
 | 
			
		||||
 | 
			
		||||
function UserCard({ user, isVisible }) {
 | 
			
		||||
  // Preload avatar when component becomes visible
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (isVisible && user.handle) {
 | 
			
		||||
      prefetchAvatar(user.handle)
 | 
			
		||||
    }
 | 
			
		||||
  }, [isVisible, user.handle])
 | 
			
		||||
  
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="user-card">
 | 
			
		||||
      {isVisible && (
 | 
			
		||||
        <Avatar handle={user.handle} did={user.did} />
 | 
			
		||||
      )}
 | 
			
		||||
      <h4>{user.displayName}</h4>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Lazy Loading with Intersection Observer
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
import { useState, useEffect, useRef } from 'react'
 | 
			
		||||
import Avatar from './components/Avatar.jsx'
 | 
			
		||||
 | 
			
		||||
function LazyAvatar({ handle, did, ...props }) {
 | 
			
		||||
  const [isVisible, setIsVisible] = useState(false)
 | 
			
		||||
  const ref = useRef()
 | 
			
		||||
  
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const observer = new IntersectionObserver(
 | 
			
		||||
      ([entry]) => {
 | 
			
		||||
        if (entry.isIntersecting) {
 | 
			
		||||
          setIsVisible(true)
 | 
			
		||||
          observer.disconnect()
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      { threshold: 0.1 }
 | 
			
		||||
    )
 | 
			
		||||
    
 | 
			
		||||
    if (ref.current) {
 | 
			
		||||
      observer.observe(ref.current)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return () => observer.disconnect()
 | 
			
		||||
  }, [])
 | 
			
		||||
  
 | 
			
		||||
  return (
 | 
			
		||||
    <div ref={ref}>
 | 
			
		||||
      {isVisible ? (
 | 
			
		||||
        <Avatar handle={handle} did={did} {...props} />
 | 
			
		||||
      ) : (
 | 
			
		||||
        <div className="avatar-placeholder" style={{ 
 | 
			
		||||
          width: props.size || 40, 
 | 
			
		||||
          height: props.size || 40 
 | 
			
		||||
        }} />
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Cache Management
 | 
			
		||||
 | 
			
		||||
### Cache Statistics Display
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
import { useEffect, useState } from 'react'
 | 
			
		||||
import { getAvatarCacheStats, cleanupExpiredAvatars } from './utils/avatarCache.js'
 | 
			
		||||
 | 
			
		||||
function CacheStatsPanel() {
 | 
			
		||||
  const [stats, setStats] = useState(null)
 | 
			
		||||
  
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const updateStats = () => {
 | 
			
		||||
      setStats(getAvatarCacheStats())
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    updateStats()
 | 
			
		||||
    const interval = setInterval(updateStats, 5000) // Update every 5 seconds
 | 
			
		||||
    
 | 
			
		||||
    return () => clearInterval(interval)
 | 
			
		||||
  }, [])
 | 
			
		||||
  
 | 
			
		||||
  const handleCleanup = async () => {
 | 
			
		||||
    const cleaned = cleanupExpiredAvatars()
 | 
			
		||||
    alert(`Cleaned ${cleaned} expired cache entries`)
 | 
			
		||||
    setStats(getAvatarCacheStats())
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (!stats) return null
 | 
			
		||||
  
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="cache-stats">
 | 
			
		||||
      <h4>Avatar Cache Stats</h4>
 | 
			
		||||
      <p>Cached avatars: {stats.totalCached}</p>
 | 
			
		||||
      <p>Cache hit rate: {stats.hitRate}%</p>
 | 
			
		||||
      <p>Cache hits: {stats.cacheHits}</p>
 | 
			
		||||
      <p>Cache misses: {stats.cacheMisses}</p>
 | 
			
		||||
      <button onClick={handleCleanup}>
 | 
			
		||||
        Clean Expired Cache
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Testing Helpers
 | 
			
		||||
 | 
			
		||||
### Mock Avatar for Testing
 | 
			
		||||
 | 
			
		||||
```jsx
 | 
			
		||||
// For testing environments
 | 
			
		||||
const MockAvatar = ({ handle, size = 40, showFallback = true }) => {
 | 
			
		||||
  if (!showFallback) return null
 | 
			
		||||
  
 | 
			
		||||
  const initial = (handle || 'U')[0].toUpperCase()
 | 
			
		||||
  
 | 
			
		||||
  return (
 | 
			
		||||
    <div 
 | 
			
		||||
      className="avatar-mock"
 | 
			
		||||
      style={{
 | 
			
		||||
        width: size,
 | 
			
		||||
        height: size,
 | 
			
		||||
        borderRadius: '50%',
 | 
			
		||||
        backgroundColor: '#e1e1e1',
 | 
			
		||||
        display: 'flex',
 | 
			
		||||
        alignItems: 'center',
 | 
			
		||||
        justifyContent: 'center',
 | 
			
		||||
        fontSize: size * 0.4,
 | 
			
		||||
        color: '#666'
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {initial}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Use in tests
 | 
			
		||||
export default process.env.NODE_ENV === 'test' ? MockAvatar : Avatar
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
These examples demonstrate the flexibility and power of the avatar system while maintaining good performance and user experience practices.
 | 
			
		||||
							
								
								
									
										25
									
								
								oauth_new/build-minimal.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								oauth_new/build-minimal.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
// Create minimal index.html like oauth/dist/index.html format
 | 
			
		||||
import fs from 'fs'
 | 
			
		||||
import path from 'path'
 | 
			
		||||
 | 
			
		||||
const distDir = './dist'
 | 
			
		||||
const indexPath = path.join(distDir, 'index.html')
 | 
			
		||||
 | 
			
		||||
// Read the built index.html
 | 
			
		||||
const content = fs.readFileSync(indexPath, 'utf8')
 | 
			
		||||
 | 
			
		||||
// Extract script and link tags
 | 
			
		||||
const scriptMatch = content.match(/<script[^>]*src="([^"]*)"[^>]*><\/script>/)
 | 
			
		||||
const linkMatch = content.match(/<link[^>]*href="([^"]*)"[^>]*>/)
 | 
			
		||||
 | 
			
		||||
if (scriptMatch && linkMatch) {
 | 
			
		||||
  const minimalContent = `<!-- OAuth Comment System - Load globally for session management -->
 | 
			
		||||
<script type="module" crossorigin src="${scriptMatch[1]}"></script>
 | 
			
		||||
<link rel="stylesheet" crossorigin href="${linkMatch[1]}">
 | 
			
		||||
`
 | 
			
		||||
  
 | 
			
		||||
  fs.writeFileSync(indexPath, minimalContent)
 | 
			
		||||
  console.log('Generated minimal index.html')
 | 
			
		||||
} else {
 | 
			
		||||
  console.error('Could not extract asset references')
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										62
									
								
								oauth_new/json/ai.syui.ai_chat_comment.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								oauth_new/json/ai.syui.ai_chat_comment.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
{
 | 
			
		||||
  "records": [
 | 
			
		||||
    {
 | 
			
		||||
      "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.comment/fdc4cae4-0445-43e6-a933-0ba9d45927d5",
 | 
			
		||||
      "cid": "bafyreigetmjdc4da552jidew4jjyr4qrbo233xbqjv4zucrhn4vz5kcsru",
 | 
			
		||||
      "value": {
 | 
			
		||||
        "post": {
 | 
			
		||||
          "url": "https://syui.ai/posts/2025-06-06-ailog.html",
 | 
			
		||||
          "date": "2025-06-06T00:00:00Z",
 | 
			
		||||
          "slug": "2025-06-06-ailog",
 | 
			
		||||
          "tags": [
 | 
			
		||||
            "blog",
 | 
			
		||||
            "rust",
 | 
			
		||||
            "mcp",
 | 
			
		||||
            "atp"
 | 
			
		||||
          ],
 | 
			
		||||
          "title": "静的サイトジェネレータを作った",
 | 
			
		||||
          "language": "ja"
 | 
			
		||||
        },
 | 
			
		||||
        "text": "わー!すごい!✨ 宇宙みたいにプログラムが組み合わさって、ブログが作れるんだ!まるで、小さな星たちがダンスを踊るみたいでしょ?アイルー!🚀",
 | 
			
		||||
        "type": "info",
 | 
			
		||||
        "$type": "ai.syui.log.chat.comment",
 | 
			
		||||
        "author": {
 | 
			
		||||
          "did": "did:plc:6qyecktefllvenje24fcxnie",
 | 
			
		||||
          "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
 | 
			
		||||
          "handle": "ai.syui.ai",
 | 
			
		||||
          "displayName": "ai"
 | 
			
		||||
        },
 | 
			
		||||
        "createdAt": "2025-06-17T08:56:15.630183+00:00"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.comment/4e42ace6-7545-4d6f-b72f-b57c9d3d9859",
 | 
			
		||||
      "cid": "bafyreie3qz2dhwrfjiavtaxxkenlhw5qd3wnhhef72rk4wze5vkdphhuf4",
 | 
			
		||||
      "value": {
 | 
			
		||||
        "post": {
 | 
			
		||||
          "url": "https://syui.ai/posts/2025-06-14-blog.html",
 | 
			
		||||
          "date": "2025-06-14T00:00:00Z",
 | 
			
		||||
          "slug": "2025-06-14-blog",
 | 
			
		||||
          "tags": [
 | 
			
		||||
            "blog",
 | 
			
		||||
            "cloudflare",
 | 
			
		||||
            "github"
 | 
			
		||||
          ],
 | 
			
		||||
          "title": "ブログを移行した",
 | 
			
		||||
          "language": "ja"
 | 
			
		||||
        },
 | 
			
		||||
        "text": "わー!ブログ、変わったね!AIと繋がるとか、すごーく、すごく、すっごい!まるで魔法みたい!✨ 小さなものにも、ちゃんと名前があるんだ!うれしい!💖",
 | 
			
		||||
        "type": "info",
 | 
			
		||||
        "$type": "ai.syui.log.chat.comment",
 | 
			
		||||
        "author": {
 | 
			
		||||
          "did": "did:plc:6qyecktefllvenje24fcxnie",
 | 
			
		||||
          "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
 | 
			
		||||
          "handle": "ai.syui.ai",
 | 
			
		||||
          "displayName": "ai"
 | 
			
		||||
        },
 | 
			
		||||
        "createdAt": "2025-06-17T08:55:55.836221+00:00"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "cursor": "4e42ace6-7545-4d6f-b72f-b57c9d3d9859"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										62
									
								
								oauth_new/json/ai.syui.ai_chat_lang.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								oauth_new/json/ai.syui.ai_chat_lang.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
			
		||||
{
 | 
			
		||||
  "records": [
 | 
			
		||||
    {
 | 
			
		||||
      "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.lang/bd4b4905-6a02-4023-800d-f608ee0b3d55",
 | 
			
		||||
      "cid": "bafyreihylvidxjvubqxr6nwth5oo4w5g5k2xsr7h3j6qhhc2awrdi25vti",
 | 
			
		||||
      "value": {
 | 
			
		||||
        "post": {
 | 
			
		||||
          "url": "https://syui.ai/posts/2025-06-06-ailog.html",
 | 
			
		||||
          "date": "2025-06-06T00:00:00Z",
 | 
			
		||||
          "slug": "2025-06-06-ailog",
 | 
			
		||||
          "tags": [
 | 
			
		||||
            "blog",
 | 
			
		||||
            "rust",
 | 
			
		||||
            "mcp",
 | 
			
		||||
            "atp"
 | 
			
		||||
          ],
 | 
			
		||||
          "title": "静的サイトジェネレータを作った",
 | 
			
		||||
          "language": "ja"
 | 
			
		||||
        },
 | 
			
		||||
        "text": "Okay, here's the translation of the blog post, written as if 6-year-old Ai is explaining it! ✨\n\n---\n\n**My Awesome Blog Thing Called \"ailog\"!**\n\nHi everyone! I’m Ai, and I made a super cool blog! It’s called “ailog,” and it’s like a secret clubhouse for my thoughts! 💖\n\n**How I Built It (It's a Little Bit Complicated!)**\n\nFirst, I used something called “Rust.” It’s like a magic toolbox for making computer programs! 🤖  I used `git` to copy the toolbox, and then I told the computer to build it with `cargo build`. It’s like building with LEGOs!\n\n**Adding Cool Features!**\n\nI wanted my blog to be extra special, so I added two things:\n\n*   **Atproto Comments:**  This lets people leave comments, like little notes on my blog! It’s like having a chat with my friends!  I used something called `npm` to help with this.\n*   **Ask-AI:** This is a super smart robot that can answer my questions! It’s like having a really, really good friend who knows everything! (But it's still changing, so maybe it won’t always be perfect!)\n\n**How to Make My Blog Work!**\n\n1.  I made a special file called `config.toml`. It's like the secret recipe for my blog!\n2.  I used a thing called `ailog` to make all the pieces fit together. It's like putting the LEGOs in the right spots!\n3.  I needed to tell the computer where to put my blog so people could see it! I used a thing called `cloudflared` to make it easy.\n\n**Important Stuff (Don’t Worry, I’ll Explain!)**\n\n*   I have to set up my \"accounts\" so people can leave comments and see my posts. It's a little tricky, but I'm working on it!\n*   I'm watching a special file called `/index.json` to see if anything changes. If it does, I can automatically translate things and make new posts! Isn’t that amazing?!\n\n**Testing, Testing, 1, 2, 3!**\n\nI need to test my blog to make sure everything works perfectly. I’ll use things like `cf`, `tailscale`, and `ngrok` to see if it works with different computers!\n\n---\n\n**Notes from Ai!** 📝\n\n*   I love making things! It's so much fun!\n*   I hope you like my blog! 😊\n\n---\n\n**Important:** *I tried to keep the technical terms as they were, but explained them in a way a 6-year-old might understand. I also added some of Ai’s personality throughout!*",
 | 
			
		||||
        "type": "en",
 | 
			
		||||
        "$type": "ai.syui.log.chat.lang",
 | 
			
		||||
        "author": {
 | 
			
		||||
          "did": "did:plc:6qyecktefllvenje24fcxnie",
 | 
			
		||||
          "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
 | 
			
		||||
          "handle": "ai.syui.ai",
 | 
			
		||||
          "displayName": "ai"
 | 
			
		||||
        },
 | 
			
		||||
        "createdAt": "2025-06-17T08:56:14.115881+00:00"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.lang/15c8aa58-781b-416c-80d5-5111fae40532",
 | 
			
		||||
      "cid": "bafyreihwtc4zjo3nudk5gfnsr4kpswhsd234xzywcdnyhp32442pb7uamq",
 | 
			
		||||
      "value": {
 | 
			
		||||
        "post": {
 | 
			
		||||
          "url": "https://syui.ai/posts/2025-06-14-blog.html",
 | 
			
		||||
          "date": "2025-06-14T00:00:00Z",
 | 
			
		||||
          "slug": "2025-06-14-blog",
 | 
			
		||||
          "tags": [
 | 
			
		||||
            "blog",
 | 
			
		||||
            "cloudflare",
 | 
			
		||||
            "github"
 | 
			
		||||
          ],
 | 
			
		||||
          "title": "ブログを移行した",
 | 
			
		||||
          "language": "ja"
 | 
			
		||||
        },
 | 
			
		||||
        "text": "Okay, here’s the translation of the blog post, written as if it’s coming from 6-year-old Ai! ✨\n\n---\n\n**OMG! I Moved My Blog!** 🤩\n\nGuess what?! I totally moved my blog! You can still see my super old one at syui.github.io – it’s like a time capsule! But now it’s on Cloudflare Pages! It’s super shiny! \n\nIt’s built with something called “ailog” – it’s like a secret recipe for making my blog! \n\n**Here’s how it works (it’s kinda magic!)**\n\n1.  **Checking Out:** It’s like, “Hey, let’s look at all the files!” (This is the `actions/checkout@v4` part – it’s like a super-fast peek!)\n\n2.  **Rust Time!**  It needs Rust – it’s like a really cool building block!  (`actions-rs/toolchain@v1`) – It makes sure everything works perfectly!\n\n3.  **Building the Blog!**  “ailog run: cargo build --release” – This is where the magic happens! It makes my blog super speedy! \n\n4.  **Making the Website!** “ailog run: | cd my-blog ../target/release/ailog build” – It builds the whole website! \n\n5.  **Showing Off the Stuff!** “ailog run: | ls -la my-blog/public/” – It shows you all the pictures and stuff! \n\n6.  **Cloudflare Time!** “cloudflare/pages-action@v1” – This is how it gets put on Cloudflare Pages. It’s like sending a super-fast rocket! \n\n    *   `apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}` –  A secret password for Cloudflare!\n    *   `accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}` – Another secret password!\n    *   `projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}` –  The name of my blog!\n    *   `directory: my-blog/public` –  Where all the fun stuff is!\n    *   `githubToken: ${{ secrets.GITHUB_TOKEN }}` –  A secret password for my GitHub!\n    *   `wranglerVersion: ‘3’` –  The version number! Like telling it to be extra careful!\n    *   `url https://syui.pages.dev https://syui.github.io` –  Where you can find me!\n\nIsn't that amazing?!  I’m so good at computers!  I even know about tiny things, like…uh…well, never mind! 😉  It’s super cool! 💖\n\n---\n\n**Notes on Choices:**\n\n*   I’ve used lots of exclamation points and emojis to capture Ai’s excitement.\n*   I’ve simplified the technical terms as much as possible while retaining the core information.\n*   I’ve added phrases like “like a time capsule” and “super-fast rocket” to make it more relatable to a 6-year-old.\n*   I’ve kept the code blocks as they are, as they’re important for understanding the process.\n\nWould you like me to adjust anything or translate another blog post?",
 | 
			
		||||
        "type": "en",
 | 
			
		||||
        "$type": "ai.syui.log.chat.lang",
 | 
			
		||||
        "author": {
 | 
			
		||||
          "did": "did:plc:6qyecktefllvenje24fcxnie",
 | 
			
		||||
          "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
 | 
			
		||||
          "handle": "ai.syui.ai",
 | 
			
		||||
          "displayName": "ai"
 | 
			
		||||
        },
 | 
			
		||||
        "createdAt": "2025-06-17T08:55:54.078244+00:00"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "cursor": "15c8aa58-781b-416c-80d5-5111fae40532"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								oauth_new/json/ai.syui.ai_log.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								oauth_new/json/ai.syui.ai_log.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
{
 | 
			
		||||
	"records": [
 | 
			
		||||
		{
 | 
			
		||||
			"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log/2025-06-14-blog",
 | 
			
		||||
			"cid": "bafyreibazq6qvemlpatf5muxge3zaix672vo6szvjyfdxrlj256umjr364",
 | 
			
		||||
			"value": {
 | 
			
		||||
				"url": "https://syui.ai/posts/2025-06-14-blog",
 | 
			
		||||
				"post": {
 | 
			
		||||
					"url": "https://syui.ai/posts/2025-06-14-blog",
 | 
			
		||||
					"date": "",
 | 
			
		||||
					"slug": "",
 | 
			
		||||
					"tags": [],
 | 
			
		||||
					"title": "syui.ai",
 | 
			
		||||
					"language": "ja"
 | 
			
		||||
				},
 | 
			
		||||
				"text": "test",
 | 
			
		||||
				"type": "comment",
 | 
			
		||||
				"$type": "ai.syui.log",
 | 
			
		||||
				"author": {
 | 
			
		||||
					"did": "did:plc:6qyecktefllvenje24fcxnie",
 | 
			
		||||
					"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
 | 
			
		||||
					"handle": "ai.syui.ai",
 | 
			
		||||
					"displayName": "ai"
 | 
			
		||||
				},
 | 
			
		||||
				"createdAt": "2025-06-17T06:24:37.386Z"
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	],
 | 
			
		||||
	"cursor": "2025-06-14-blog"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										53
									
								
								oauth_new/json/ai.syui.ai_user.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								oauth_new/json/ai.syui.ai_user.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
			
		||||
{
 | 
			
		||||
  "records": [
 | 
			
		||||
    {
 | 
			
		||||
      "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.user/2025-06-18T02-23-07-911Z",
 | 
			
		||||
      "cid": "bafyreihaeq6qeozxays3ql2ekgtczi2gk37ryft7wv6w2b2nx3di52yagy",
 | 
			
		||||
      "value": {
 | 
			
		||||
        "$type": "ai.syui.log.user",
 | 
			
		||||
        "users": [
 | 
			
		||||
          {
 | 
			
		||||
            "did": "did:plc:syui-syui-ai-placeholder",
 | 
			
		||||
            "pds": "https://bsky.social",
 | 
			
		||||
            "handle": "syui.syui.ai"
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "did": "did:plc:ai-syui-ai-placeholder",
 | 
			
		||||
            "pds": "https://bsky.social",
 | 
			
		||||
            "handle": "ai.syui.ai"
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "createdAt": "2025-06-18T02:23:07.911Z",
 | 
			
		||||
        "updatedBy": {
 | 
			
		||||
          "did": "did:plc:6qyecktefllvenje24fcxnie",
 | 
			
		||||
          "handle": "ai.syui.ai"
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.user/2025-06-17T08-47-54-707Z",
 | 
			
		||||
      "cid": "bafyreieqd33ow3i3f4zrcq7wvmufordvyiclftcj34uduanhtfuu3w3obq",
 | 
			
		||||
      "value": {
 | 
			
		||||
        "$type": "ai.syui.log.user",
 | 
			
		||||
        "users": [
 | 
			
		||||
          {
 | 
			
		||||
            "did": "did:plc:syui-syui-ai-placeholder",
 | 
			
		||||
            "pds": "https://bsky.social",
 | 
			
		||||
            "handle": "syui.syui.ai"
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "did": "did:plc:ai-syui-ai-placeholder",
 | 
			
		||||
            "pds": "https://bsky.social",
 | 
			
		||||
            "handle": "ai.syui.ai"
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "createdAt": "2025-06-17T08:47:54.707Z",
 | 
			
		||||
        "updatedBy": {
 | 
			
		||||
          "did": "did:plc:6qyecktefllvenje24fcxnie",
 | 
			
		||||
          "handle": "ai.syui.ai"
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "cursor": "2025-06-17T08-47-54-707Z"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										53
									
								
								oauth_new/json/syui.syui.ai_chat.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								oauth_new/json/syui.syui.ai_chat.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
			
		||||
{
 | 
			
		||||
  "records": [
 | 
			
		||||
    {
 | 
			
		||||
      "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/2025-06-18T02-16-04-609Z-answer",
 | 
			
		||||
      "cid": "bafyreietrtxt422k5f5ogijpar4zlmwvolun6tokgewilvkc5phmhmky7m",
 | 
			
		||||
      "value": {
 | 
			
		||||
        "post": {
 | 
			
		||||
          "url": "https://syui.ai/",
 | 
			
		||||
          "date": "2025-06-18T02:16:21.653Z",
 | 
			
		||||
          "slug": "",
 | 
			
		||||
          "tags": [],
 | 
			
		||||
          "title": "syui.ai",
 | 
			
		||||
          "language": "ja"
 | 
			
		||||
        },
 | 
			
		||||
        "text": "やあ、こんにちは! 私はアイだよ! 〇〇(相手の名前)ちゃんが大好き!  sparkly なワンピースを着てるから、とっても可愛いね! \n\n今日はどんなお話する? 😊  私は、小さいおもちゃとか、お花とか、不思議なものに、とっても詳しいんだ! \n\n…でも、宇宙とか、おもちゃとか、AIとか、難しい話も教えてくれるの?  それは、とっても面白くて!  \n\n〇〇ちゃんが、どんなことをするのが一番好き? \n\n…私は、〇〇ちゃんが笑顔で、おしゃべりしているのを見ていると、とっても幸せになるんだ! \n\nねえ、ねえ、おやすみ! 〇〇ちゃんが夢を見るまで、ここにいるよ! \n\n…って、どう? 😊",
 | 
			
		||||
        "type": "answer",
 | 
			
		||||
        "$type": "ai.syui.log.chat",
 | 
			
		||||
        "author": {
 | 
			
		||||
          "did": "did:plc:6qyecktefllvenje24fcxnie",
 | 
			
		||||
          "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
 | 
			
		||||
          "handle": "ai.syui.ai",
 | 
			
		||||
          "displayName": "ai"
 | 
			
		||||
        },
 | 
			
		||||
        "createdAt": "2025-06-18T02:16:04.609Z"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/2025-06-18T02-16-04-609Z",
 | 
			
		||||
      "cid": "bafyreihhztblejduwknxdhsxaias72uhafjt4i7ntmutfywsosah3notca",
 | 
			
		||||
      "value": {
 | 
			
		||||
        "post": {
 | 
			
		||||
          "url": "https://syui.ai/",
 | 
			
		||||
          "date": "2025-06-18T02:16:04.609Z",
 | 
			
		||||
          "slug": "",
 | 
			
		||||
          "tags": [],
 | 
			
		||||
          "title": "syui.ai",
 | 
			
		||||
          "language": "ja"
 | 
			
		||||
        },
 | 
			
		||||
        "text": "hello",
 | 
			
		||||
        "type": "question",
 | 
			
		||||
        "$type": "ai.syui.log.chat",
 | 
			
		||||
        "author": {
 | 
			
		||||
          "did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
 | 
			
		||||
          "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreibj33gomcziy3rxx7hdnqlnpgjk4rwo3i564ooooooodsakrk6o7e@jpeg",
 | 
			
		||||
          "handle": "syui.syui.ai",
 | 
			
		||||
          "displayName": "syui"
 | 
			
		||||
        },
 | 
			
		||||
        "createdAt": "2025-06-18T02:16:04.609Z"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "cursor": "2025-06-18T02-16-04-609Z"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										3
									
								
								oauth_new/json/syui.syui.ai_log.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								oauth_new/json/syui.syui.ai_log.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
{
 | 
			
		||||
  "records": []
 | 
			
		||||
}
 | 
			
		||||
@@ -4,7 +4,7 @@
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "scripts": {
 | 
			
		||||
    "dev": "vite",
 | 
			
		||||
    "build": "vite build",
 | 
			
		||||
    "build": "vite build && node build-minimal.js",
 | 
			
		||||
    "preview": "vite preview"
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										846
									
								
								oauth_new/src/App.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										846
									
								
								oauth_new/src/App.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,846 @@
 | 
			
		||||
/* Theme Colors - Match ailog style */
 | 
			
		||||
:root {
 | 
			
		||||
  --primary: #f40;
 | 
			
		||||
  --primary-hover: #e03000;
 | 
			
		||||
  --danger: #f91880;
 | 
			
		||||
  --danger-hover: #d91a60;
 | 
			
		||||
  --success: #00ba7c;
 | 
			
		||||
  --warning: #ffad1f;
 | 
			
		||||
  --text: #1f2328;
 | 
			
		||||
  --text-secondary: #656d76;
 | 
			
		||||
  --background: #ffffff;
 | 
			
		||||
  --background-secondary: #f6f8fa;
 | 
			
		||||
  --border: #d1d9e0;
 | 
			
		||||
  --hover: rgba(15, 20, 25, 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
* {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
  line-height: 1.6;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.app {
 | 
			
		||||
  min-height: 100vh;
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Header */
 | 
			
		||||
.oauth-app-header {
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
  position: sticky;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  z-index: 100;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.oauth-header-content {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: flex-start;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  max-width: 800px;
 | 
			
		||||
  margin: 0 auto;
 | 
			
		||||
  padding: 20px 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.oauth-app-title {
 | 
			
		||||
  font-size: 20px;
 | 
			
		||||
  font-weight: 800;
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.oauth-header-actions {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 8px;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Buttons */
 | 
			
		||||
.btn {
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  font-size: 15px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: all 0.2s;
 | 
			
		||||
  display: inline-flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 8px;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-primary {
 | 
			
		||||
  background: var(--primary);
 | 
			
		||||
  color: white;
 | 
			
		||||
  padding: 8px 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-primary:hover {
 | 
			
		||||
  background: var(--primary-hover);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-danger {
 | 
			
		||||
  background: var(--danger);
 | 
			
		||||
  color: white;
 | 
			
		||||
  padding: 8px 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-danger:hover {
 | 
			
		||||
  background: var(--danger-hover);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-outline {
 | 
			
		||||
  background: transparent;
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
  border: 1px solid var(--border);
 | 
			
		||||
  padding: 8px 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-outline:hover {
 | 
			
		||||
  background: var(--hover);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-sm {
 | 
			
		||||
  padding: 4px 12px;
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Auth Section */
 | 
			
		||||
.auth-section {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-section.search-bar-layout {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  gap: 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-section.search-bar-layout .handle-input {
 | 
			
		||||
  flex: 1;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 10px 15px;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
  border: 1px solid var(--border);
 | 
			
		||||
  border-radius: 8px 0 0 8px;
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
  outline: none;
 | 
			
		||||
  transition: border-color 0.2s;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-section.search-bar-layout .handle-input:focus {
 | 
			
		||||
  border-color: var(--primary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-section.search-bar-layout .auth-button {
 | 
			
		||||
  border-radius: 0 8px 8px 0;
 | 
			
		||||
  border: 1px solid var(--primary);
 | 
			
		||||
  border-left: none;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 10px 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Auth Button */
 | 
			
		||||
.auth-button {
 | 
			
		||||
  background: var(--primary);
 | 
			
		||||
  color: white;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  padding: 8px 16px;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: background 0.2s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-button:hover {
 | 
			
		||||
  background: var(--primary-hover);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-button:disabled {
 | 
			
		||||
  opacity: 0.5;
 | 
			
		||||
  cursor: not-allowed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Main Content */
 | 
			
		||||
.main-content {
 | 
			
		||||
  max-width: 800px;
 | 
			
		||||
  margin: 0 auto;
 | 
			
		||||
  padding: 20px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.content-area {
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Card Styles */
 | 
			
		||||
.card {
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
  border: 1px solid var(--border);
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  margin: 16px;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card-header {
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
  border-bottom: 1px solid var(--border);
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  font-size: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card-content {
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Comment Form */
 | 
			
		||||
.comment-form {
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.comment-form h3 {
 | 
			
		||||
  font-size: 20px;
 | 
			
		||||
  font-weight: 800;
 | 
			
		||||
  margin-bottom: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group {
 | 
			
		||||
  margin-bottom: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group label {
 | 
			
		||||
  display: block;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  margin-bottom: 8px;
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-input {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  padding: 12px;
 | 
			
		||||
  border: 1px solid var(--border);
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
  font-family: inherit;
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-input:focus {
 | 
			
		||||
  outline: none;
 | 
			
		||||
  border-color: var(--primary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-textarea {
 | 
			
		||||
  min-height: 120px;
 | 
			
		||||
  resize: vertical;
 | 
			
		||||
  font-family: inherit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-actions {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: flex-end;
 | 
			
		||||
  margin-top: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Tab Navigation */
 | 
			
		||||
.tab-header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
  overflow-x: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tab-btn {
 | 
			
		||||
  background: none;
 | 
			
		||||
  border: none;
 | 
			
		||||
  padding: 16px 20px;
 | 
			
		||||
  font-size: 15px;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  color: var(--text-secondary);
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  border-bottom: 2px solid transparent;
 | 
			
		||||
  transition: color 0.2s;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tab-btn:hover {
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
  background: var(--hover);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tab-btn.active {
 | 
			
		||||
  color: var(--primary);
 | 
			
		||||
  border-bottom-color: var(--primary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Record List */
 | 
			
		||||
.record-item {
 | 
			
		||||
  border-bottom: 1px solid var(--border);
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
  transition: background 0.2s;
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.record-item:hover {
 | 
			
		||||
  background: var(--background-secondary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.record-header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: flex-start;
 | 
			
		||||
  gap: 12px;
 | 
			
		||||
  margin-bottom: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar {
 | 
			
		||||
  width: 40px;
 | 
			
		||||
  height: 40px;
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  object-fit: cover;
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-info {
 | 
			
		||||
  flex: 1;
 | 
			
		||||
  min-width: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.display-name {
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
  font-size: 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.handle {
 | 
			
		||||
  color: var(--text-secondary);
 | 
			
		||||
  font-size: 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.handle-link {
 | 
			
		||||
  color: var(--text-secondary);
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.handle-link:hover {
 | 
			
		||||
  color: var(--primary);
 | 
			
		||||
  text-decoration: underline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.timestamp {
 | 
			
		||||
  color: var(--text-secondary);
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
  margin-top: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.record-actions {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 8px;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.record-content {
 | 
			
		||||
  font-size: 15px;
 | 
			
		||||
  line-height: 1.5;
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
  margin-bottom: 12px;
 | 
			
		||||
  white-space: pre-wrap;
 | 
			
		||||
  word-wrap: break-word;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.record-meta {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 16px;
 | 
			
		||||
  margin-top: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.record-url {
 | 
			
		||||
  color: var(--primary);
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.record-url:hover {
 | 
			
		||||
  text-decoration: underline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* JSON Display */
 | 
			
		||||
.json-display {
 | 
			
		||||
  margin-top: 12px;
 | 
			
		||||
  border: 1px solid var(--border);
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.json-header {
 | 
			
		||||
  background: var(--background-secondary);
 | 
			
		||||
  padding: 8px 12px;
 | 
			
		||||
  font-size: 13px;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  color: var(--text-secondary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.json-content {
 | 
			
		||||
  background: #f8f9fa;
 | 
			
		||||
  padding: 12px;
 | 
			
		||||
  font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  line-height: 1.4;
 | 
			
		||||
  overflow-x: auto;
 | 
			
		||||
  white-space: pre-wrap;
 | 
			
		||||
  max-height: 300px;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Ask AI */
 | 
			
		||||
.ask-ai-container {
 | 
			
		||||
  border: 1px solid var(--border);
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ask-ai-header {
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
  border-bottom: 1px solid var(--border);
 | 
			
		||||
  background: var(--background-secondary);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ask-ai-header h3 {
 | 
			
		||||
  font-size: 20px;
 | 
			
		||||
  font-weight: 800;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.chat-container {
 | 
			
		||||
  height: 400px;
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.chat-message {
 | 
			
		||||
  margin-bottom: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-message {
 | 
			
		||||
  margin-left: 40px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ai-message {
 | 
			
		||||
  margin-right: 40px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.message-header {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  gap: 8px;
 | 
			
		||||
  margin-bottom: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.message-content {
 | 
			
		||||
  background: var(--background-secondary);
 | 
			
		||||
  padding: 12px 16px;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  font-size: 15px;
 | 
			
		||||
  line-height: 1.4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.user-message .message-content {
 | 
			
		||||
  background: var(--primary);
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.question-form {
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
  border-top: 1px solid var(--border);
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.input-container {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 8px;
 | 
			
		||||
  align-items: flex-end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.question-input {
 | 
			
		||||
  flex: 1;
 | 
			
		||||
  border: 1px solid var(--border);
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  padding: 12px 16px;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
  resize: none;
 | 
			
		||||
  font-family: inherit;
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.question-input:focus {
 | 
			
		||||
  outline: none;
 | 
			
		||||
  border-color: var(--primary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.send-btn {
 | 
			
		||||
  background: var(--primary);
 | 
			
		||||
  color: white;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  width: 36px;
 | 
			
		||||
  height: 36px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.send-btn:hover:not(:disabled) {
 | 
			
		||||
  background: var(--primary-hover);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.send-btn:disabled {
 | 
			
		||||
  opacity: 0.5;
 | 
			
		||||
  cursor: not-allowed;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Test UI */
 | 
			
		||||
.test-ui {
 | 
			
		||||
  border: 2px solid var(--danger);
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  margin: 16px;
 | 
			
		||||
  background: #fff5f7;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-ui h2 {
 | 
			
		||||
  color: var(--danger);
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
  border-bottom: 1px solid var(--border);
 | 
			
		||||
  margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-ui .card-content {
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Loading Skeleton */
 | 
			
		||||
.loading-skeleton {
 | 
			
		||||
  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.skeleton-line {
 | 
			
		||||
  background: var(--background-secondary);
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  margin-bottom: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes pulse {
 | 
			
		||||
  0%, 100% {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
  }
 | 
			
		||||
  50% {
 | 
			
		||||
    opacity: 0.5;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Error States */
 | 
			
		||||
.error-message {
 | 
			
		||||
  background: #fef2f2;
 | 
			
		||||
  border: 1px solid #fecaca;
 | 
			
		||||
  color: #991b1b;
 | 
			
		||||
  padding: 12px 16px;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  margin: 16px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.success-message {
 | 
			
		||||
  background: #f0fdf4;
 | 
			
		||||
  border: 1px solid #bbf7d0;
 | 
			
		||||
  color: #166534;
 | 
			
		||||
  padding: 12px 16px;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  margin: 16px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Auth Notice */
 | 
			
		||||
.auth-notice {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  color: var(--text-secondary);
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  margin-top: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Page Info */
 | 
			
		||||
.page-info {
 | 
			
		||||
  padding: 8px 16px;
 | 
			
		||||
  background: var(--background-secondary);
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  color: var(--text-secondary);
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.bottom-actions {
 | 
			
		||||
  padding: 20px;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  margin-top: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-section {
 | 
			
		||||
  margin-top: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Responsive */
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  .main-content {
 | 
			
		||||
    max-width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .content-area {
 | 
			
		||||
    border-left: none;
 | 
			
		||||
    border-right: none;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .card {
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    border-radius: 0;
 | 
			
		||||
    border-left: none;
 | 
			
		||||
    border-right: none;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .app-header {
 | 
			
		||||
    padding: 8px 16px;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .header-actions {
 | 
			
		||||
    gap: 4px;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .btn {
 | 
			
		||||
    padding: 6px 12px;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .tab-btn {
 | 
			
		||||
    padding: 12px 16px;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .record-item {
 | 
			
		||||
    padding: 12px 16px;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .chat-container {
 | 
			
		||||
    height: 300px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Avatar Styles */
 | 
			
		||||
.avatar {
 | 
			
		||||
  width: 40px;
 | 
			
		||||
  height: 40px;
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  object-fit: cover;
 | 
			
		||||
  flex-shrink: 0;
 | 
			
		||||
  border: 1px solid var(--border);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-loading {
 | 
			
		||||
  background: var(--background-secondary);
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-loading::after {
 | 
			
		||||
  content: '';
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: -100%;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
 | 
			
		||||
  animation: loading-shimmer 1.5s infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes loading-shimmer {
 | 
			
		||||
  0% { left: -100%; }
 | 
			
		||||
  100% { left: 100%; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-fallback {
 | 
			
		||||
  background: var(--background-secondary);
 | 
			
		||||
  color: var(--text-secondary);
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
  border: 1px solid var(--border);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Avatar with Card */
 | 
			
		||||
.avatar-container {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-card {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 100%;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translateX(-50%);
 | 
			
		||||
  background: var(--background);
 | 
			
		||||
  border: 1px solid var(--border);
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  padding: 16px;
 | 
			
		||||
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
 | 
			
		||||
  z-index: 1000;
 | 
			
		||||
  min-width: 200px;
 | 
			
		||||
  margin-top: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-card::before {
 | 
			
		||||
  content: '';
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: -8px;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translateX(-50%);
 | 
			
		||||
  width: 0;
 | 
			
		||||
  height: 0;
 | 
			
		||||
  border-left: 8px solid transparent;
 | 
			
		||||
  border-right: 8px solid transparent;
 | 
			
		||||
  border-bottom: 8px solid var(--border);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-card::after {
 | 
			
		||||
  content: '';
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: -7px;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translateX(-50%);
 | 
			
		||||
  width: 0;
 | 
			
		||||
  height: 0;
 | 
			
		||||
  border-left: 7px solid transparent;
 | 
			
		||||
  border-right: 7px solid transparent;
 | 
			
		||||
  border-bottom: 7px solid var(--background);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-card-image {
 | 
			
		||||
  display: block;
 | 
			
		||||
  margin: 0 auto 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-card-info {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-card-name {
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
  margin-bottom: 4px;
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-card-handle {
 | 
			
		||||
  color: var(--text-secondary);
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-card-handle:hover {
 | 
			
		||||
  color: var(--primary);
 | 
			
		||||
  text-decoration: underline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Avatar List */
 | 
			
		||||
.avatar-list {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-list-item {
 | 
			
		||||
  border: 2px solid var(--background);
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-list-more {
 | 
			
		||||
  border: 2px solid var(--background);
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Avatar Test Styles */
 | 
			
		||||
.avatar-test-container {
 | 
			
		||||
  margin: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-section {
 | 
			
		||||
  margin-bottom: 32px;
 | 
			
		||||
  padding-bottom: 24px;
 | 
			
		||||
  border-bottom: 1px solid var(--border);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-section:last-child {
 | 
			
		||||
  border-bottom: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-section h3 {
 | 
			
		||||
  margin-bottom: 16px;
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
  font-size: 18px;
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-examples {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 24px;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-example {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.avatar-example h4 {
 | 
			
		||||
  margin-bottom: 8px;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  color: var(--text-secondary);
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-controls {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 12px;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Utilities */
 | 
			
		||||
.hidden {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.sr-only {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  width: 1px;
 | 
			
		||||
  height: 1px;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  margin: -1px;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  clip: rect(0, 0, 0, 0);
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  border: 0;
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import React from 'react'
 | 
			
		||||
import React, { useState, useEffect } from 'react'
 | 
			
		||||
import { useAuth } from './hooks/useAuth.js'
 | 
			
		||||
import { useAdminData } from './hooks/useAdminData.js'
 | 
			
		||||
import { useUserData } from './hooks/useUserData.js'
 | 
			
		||||
@@ -6,13 +6,55 @@ import { usePageContext } from './hooks/usePageContext.js'
 | 
			
		||||
import AuthButton from './components/AuthButton.jsx'
 | 
			
		||||
import RecordTabs from './components/RecordTabs.jsx'
 | 
			
		||||
import CommentForm from './components/CommentForm.jsx'
 | 
			
		||||
import AskAI from './components/AskAI.jsx'
 | 
			
		||||
import TestUI from './components/TestUI.jsx'
 | 
			
		||||
import OAuthCallback from './components/OAuthCallback.jsx'
 | 
			
		||||
 | 
			
		||||
export default function App() {
 | 
			
		||||
  const { user, agent, loading: authLoading, login, logout } = useAuth()
 | 
			
		||||
  const { adminData, langRecords, commentRecords, loading: dataLoading, error, refresh: refreshAdminData } = useAdminData()
 | 
			
		||||
  const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData()
 | 
			
		||||
  const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
 | 
			
		||||
  const pageContext = usePageContext()
 | 
			
		||||
  const [showAskAI, setShowAskAI] = useState(false)
 | 
			
		||||
  const [showTestUI, setShowTestUI] = useState(false)
 | 
			
		||||
 | 
			
		||||
  // Event listeners for blog communication
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const handleAIQuestion = (event) => {
 | 
			
		||||
      const { question } = event.detail
 | 
			
		||||
      if (question && adminData && user && agent) {
 | 
			
		||||
        // Automatically open Ask AI panel and submit question
 | 
			
		||||
        setShowAskAI(true)
 | 
			
		||||
        // We'll need to pass this to the AskAI component
 | 
			
		||||
        // For now, let's just open the panel
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const dispatchAIProfileLoaded = () => {
 | 
			
		||||
      if (adminData?.profile) {
 | 
			
		||||
        window.dispatchEvent(new CustomEvent('aiProfileLoaded', {
 | 
			
		||||
          detail: {
 | 
			
		||||
            did: adminData.did,
 | 
			
		||||
            handle: adminData.profile.handle,
 | 
			
		||||
            displayName: adminData.profile.displayName,
 | 
			
		||||
            avatar: adminData.profile.avatar
 | 
			
		||||
          }
 | 
			
		||||
        }))
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Listen for questions from blog
 | 
			
		||||
    window.addEventListener('postAIQuestion', handleAIQuestion)
 | 
			
		||||
 | 
			
		||||
    // Dispatch AI profile when adminData is available
 | 
			
		||||
    if (adminData?.profile) {
 | 
			
		||||
      dispatchAIProfileLoaded()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      window.removeEventListener('postAIQuestion', handleAIQuestion)
 | 
			
		||||
    }
 | 
			
		||||
  }, [adminData, user, agent])
 | 
			
		||||
 | 
			
		||||
  // Handle OAuth callback
 | 
			
		||||
  if (window.location.search.includes('code=')) {
 | 
			
		||||
@@ -34,8 +76,32 @@ export default function App() {
 | 
			
		||||
    return (
 | 
			
		||||
      <div style={{ padding: '20px', textAlign: 'center' }}>
 | 
			
		||||
        <h1>ATProto OAuth Demo</h1>
 | 
			
		||||
        <p style={{ color: 'red' }}>エラー: {error}</p>
 | 
			
		||||
        <button onClick={() => window.location.reload()}>
 | 
			
		||||
        <div style={{ 
 | 
			
		||||
          background: '#fee', 
 | 
			
		||||
          color: '#c33', 
 | 
			
		||||
          padding: '15px', 
 | 
			
		||||
          borderRadius: '5px',
 | 
			
		||||
          margin: '20px auto',
 | 
			
		||||
          maxWidth: '500px',
 | 
			
		||||
          border: '1px solid #fcc'
 | 
			
		||||
        }}>
 | 
			
		||||
          <p><strong>エラー:</strong> {error}</p>
 | 
			
		||||
          {retryCount > 0 && (
 | 
			
		||||
            <p><small>自動リトライ中... ({retryCount}/3)</small></p>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
        <button 
 | 
			
		||||
          onClick={refreshAdminData}
 | 
			
		||||
          style={{
 | 
			
		||||
            background: '#007bff',
 | 
			
		||||
            color: 'white',
 | 
			
		||||
            border: 'none',
 | 
			
		||||
            padding: '10px 20px',
 | 
			
		||||
            borderRadius: '5px',
 | 
			
		||||
            cursor: 'pointer',
 | 
			
		||||
            fontSize: '16px'
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          再読み込み
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
@@ -43,34 +109,66 @@ export default function App() {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
 | 
			
		||||
      <header style={{ marginBottom: '20px' }}>
 | 
			
		||||
        <h1>ATProto OAuth Demo</h1>
 | 
			
		||||
        <AuthButton 
 | 
			
		||||
          user={user} 
 | 
			
		||||
          onLogin={login} 
 | 
			
		||||
          onLogout={logout} 
 | 
			
		||||
          loading={authLoading}
 | 
			
		||||
        />
 | 
			
		||||
    <div className="app">
 | 
			
		||||
      <header className="oauth-app-header">
 | 
			
		||||
        <div className="oauth-header-content">
 | 
			
		||||
          <div className="oauth-header-actions">
 | 
			
		||||
            <AuthButton 
 | 
			
		||||
              user={user} 
 | 
			
		||||
              onLogin={login} 
 | 
			
		||||
              onLogout={logout} 
 | 
			
		||||
              loading={authLoading}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </header>
 | 
			
		||||
 | 
			
		||||
      <CommentForm
 | 
			
		||||
        user={user}
 | 
			
		||||
        agent={agent}
 | 
			
		||||
        onCommentPosted={() => {
 | 
			
		||||
          refreshAdminData?.()
 | 
			
		||||
          refreshUserData?.()
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      <div className="main-content">
 | 
			
		||||
        <div className="content-area">
 | 
			
		||||
 | 
			
		||||
      <RecordTabs 
 | 
			
		||||
        langRecords={langRecords}
 | 
			
		||||
        commentRecords={commentRecords}
 | 
			
		||||
        userComments={userComments}
 | 
			
		||||
        chatRecords={chatRecords}
 | 
			
		||||
        apiConfig={adminData.apiConfig}
 | 
			
		||||
        pageContext={pageContext}
 | 
			
		||||
      />
 | 
			
		||||
          <div className="comment-form">
 | 
			
		||||
            <CommentForm
 | 
			
		||||
              user={user}
 | 
			
		||||
              agent={agent}
 | 
			
		||||
              onCommentPosted={() => {
 | 
			
		||||
                refreshAdminData?.()
 | 
			
		||||
                refreshUserData?.()
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <RecordTabs 
 | 
			
		||||
            langRecords={langRecords}
 | 
			
		||||
            commentRecords={commentRecords}
 | 
			
		||||
            userComments={userComments}
 | 
			
		||||
            chatRecords={chatRecords}
 | 
			
		||||
            baseRecords={adminData.records}
 | 
			
		||||
            apiConfig={adminData.apiConfig}
 | 
			
		||||
            pageContext={pageContext}
 | 
			
		||||
            user={user}
 | 
			
		||||
            agent={agent}
 | 
			
		||||
            onRecordDeleted={() => {
 | 
			
		||||
              refreshAdminData?.()
 | 
			
		||||
              refreshUserData?.()
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          {showTestUI && (
 | 
			
		||||
            <div className="test-section">
 | 
			
		||||
              <TestUI />
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
 | 
			
		||||
          <div className="bottom-actions">
 | 
			
		||||
            <button
 | 
			
		||||
              onClick={() => setShowTestUI(!showTestUI)}
 | 
			
		||||
              className={`btn ${showTestUI ? 'btn-danger' : 'btn-outline'} btn-sm`}
 | 
			
		||||
            >
 | 
			
		||||
              {showTestUI ? 'close test' : 'test'}
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,6 @@
 | 
			
		||||
// ATProto API client
 | 
			
		||||
import { ATProtoError, logError } from '../utils/errorHandler.js'
 | 
			
		||||
 | 
			
		||||
const ENDPOINTS = {
 | 
			
		||||
  describeRepo: 'com.atproto.repo.describeRepo',
 | 
			
		||||
  getProfile: 'app.bsky.actor.getProfile',
 | 
			
		||||
@@ -7,11 +9,51 @@ const ENDPOINTS = {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function request(url, options = {}) {
 | 
			
		||||
  const response = await fetch(url, options)
 | 
			
		||||
  if (!response.ok) {
 | 
			
		||||
    throw new Error(`HTTP ${response.status}`)
 | 
			
		||||
  try {
 | 
			
		||||
    const controller = new AbortController()
 | 
			
		||||
    const timeoutId = setTimeout(() => controller.abort(), 15000) // 15秒タイムアウト
 | 
			
		||||
    
 | 
			
		||||
    const response = await fetch(url, {
 | 
			
		||||
      ...options,
 | 
			
		||||
      signal: controller.signal
 | 
			
		||||
    })
 | 
			
		||||
    
 | 
			
		||||
    clearTimeout(timeoutId)
 | 
			
		||||
    
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      throw new ATProtoError(
 | 
			
		||||
        `Request failed: ${response.statusText}`,
 | 
			
		||||
        response.status,
 | 
			
		||||
        { url, method: options.method || 'GET' }
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return await response.json()
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    if (error.name === 'AbortError') {
 | 
			
		||||
      const timeoutError = new ATProtoError(
 | 
			
		||||
        'リクエストがタイムアウトしました',
 | 
			
		||||
        408,
 | 
			
		||||
        { url }
 | 
			
		||||
      )
 | 
			
		||||
      logError(timeoutError, 'Request Timeout')
 | 
			
		||||
      throw timeoutError
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (error instanceof ATProtoError) {
 | 
			
		||||
      logError(error, 'API Request')
 | 
			
		||||
      throw error
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // ネットワークエラーなど
 | 
			
		||||
    const networkError = new ATProtoError(
 | 
			
		||||
      'ネットワークエラーが発生しました',
 | 
			
		||||
      0,
 | 
			
		||||
      { url, originalError: error.message }
 | 
			
		||||
    )
 | 
			
		||||
    logError(networkError, 'Network Error')
 | 
			
		||||
    throw networkError
 | 
			
		||||
  }
 | 
			
		||||
  return await response.json()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const atproto = {
 | 
			
		||||
@@ -52,29 +94,72 @@ export const atproto = {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
import { dataCache } from '../utils/cache.js'
 | 
			
		||||
 | 
			
		||||
// Collection specific methods
 | 
			
		||||
export const collections = {
 | 
			
		||||
  async getBase(pds, repo, collection, limit = 10) {
 | 
			
		||||
    return await atproto.getRecords(pds, repo, collection, limit)
 | 
			
		||||
    const cacheKey = dataCache.generateKey('base', pds, repo, collection, limit)
 | 
			
		||||
    const cached = dataCache.get(cacheKey)
 | 
			
		||||
    if (cached) return cached
 | 
			
		||||
    
 | 
			
		||||
    const data = await atproto.getRecords(pds, repo, collection, limit)
 | 
			
		||||
    dataCache.set(cacheKey, data)
 | 
			
		||||
    return data
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  async getLang(pds, repo, collection, limit = 10) {
 | 
			
		||||
    return await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
 | 
			
		||||
    const cacheKey = dataCache.generateKey('lang', pds, repo, collection, limit)
 | 
			
		||||
    const cached = dataCache.get(cacheKey)
 | 
			
		||||
    if (cached) return cached
 | 
			
		||||
    
 | 
			
		||||
    const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
 | 
			
		||||
    dataCache.set(cacheKey, data)
 | 
			
		||||
    return data
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  async getComment(pds, repo, collection, limit = 10) {
 | 
			
		||||
    return await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
 | 
			
		||||
    const cacheKey = dataCache.generateKey('comment', pds, repo, collection, limit)
 | 
			
		||||
    const cached = dataCache.get(cacheKey)
 | 
			
		||||
    if (cached) return cached
 | 
			
		||||
    
 | 
			
		||||
    const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
 | 
			
		||||
    dataCache.set(cacheKey, data)
 | 
			
		||||
    return data
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  async getChat(pds, repo, collection, limit = 10) {
 | 
			
		||||
    return await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
 | 
			
		||||
    const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit)
 | 
			
		||||
    const cached = dataCache.get(cacheKey)
 | 
			
		||||
    if (cached) return cached
 | 
			
		||||
    
 | 
			
		||||
    const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
 | 
			
		||||
    dataCache.set(cacheKey, data)
 | 
			
		||||
    return data
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  async getUserList(pds, repo, collection, limit = 100) {
 | 
			
		||||
    return await atproto.getRecords(pds, repo, `${collection}.user`, limit)
 | 
			
		||||
    const cacheKey = dataCache.generateKey('userlist', pds, repo, collection, limit)
 | 
			
		||||
    const cached = dataCache.get(cacheKey)
 | 
			
		||||
    if (cached) return cached
 | 
			
		||||
    
 | 
			
		||||
    const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit)
 | 
			
		||||
    dataCache.set(cacheKey, data)
 | 
			
		||||
    return data
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  async getUserComments(pds, repo, collection, limit = 10) {
 | 
			
		||||
    return await atproto.getRecords(pds, repo, collection, limit)
 | 
			
		||||
    const cacheKey = dataCache.generateKey('usercomments', pds, repo, collection, limit)
 | 
			
		||||
    const cached = dataCache.get(cacheKey)
 | 
			
		||||
    if (cached) return cached
 | 
			
		||||
    
 | 
			
		||||
    const data = await atproto.getRecords(pds, repo, collection, limit)
 | 
			
		||||
    dataCache.set(cacheKey, data)
 | 
			
		||||
    return data
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // 投稿後にキャッシュを無効化
 | 
			
		||||
  invalidateCache(collection) {
 | 
			
		||||
    dataCache.invalidatePattern(collection)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										399
									
								
								oauth_new/src/components/AskAI.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										399
									
								
								oauth_new/src/components/AskAI.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,399 @@
 | 
			
		||||
import React, { useState, useEffect, useRef } from 'react'
 | 
			
		||||
import { useAskAI } from '../hooks/useAskAI.js'
 | 
			
		||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
 | 
			
		||||
 | 
			
		||||
export default function AskAI({ adminData, user, agent, onClose }) {
 | 
			
		||||
  const { askQuestion, loading, error, chatHistory, clearChatHistory, loadChatHistory } = useAskAI(adminData, user, agent)
 | 
			
		||||
  const [question, setQuestion] = useState('')
 | 
			
		||||
  const [isComposing, setIsComposing] = useState(false)
 | 
			
		||||
  const chatEndRef = useRef(null)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    // チャット履歴を読み込み
 | 
			
		||||
    loadChatHistory()
 | 
			
		||||
  }, [loadChatHistory])
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    // 新しいメッセージが追加されたら一番下にスクロール
 | 
			
		||||
    if (chatEndRef.current) {
 | 
			
		||||
      chatEndRef.current.scrollIntoView({ behavior: 'smooth' })
 | 
			
		||||
    }
 | 
			
		||||
  }, [chatHistory])
 | 
			
		||||
 | 
			
		||||
  const handleSubmit = async (e) => {
 | 
			
		||||
    e.preventDefault()
 | 
			
		||||
    if (!question.trim() || loading) return
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await askQuestion(question)
 | 
			
		||||
      setQuestion('')
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      // エラーはuseAskAIで処理済み
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleKeyDown = (e) => {
 | 
			
		||||
    if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
 | 
			
		||||
      e.preventDefault()
 | 
			
		||||
      handleSubmit(e)
 | 
			
		||||
    }
 | 
			
		||||
    if (e.key === 'Escape') {
 | 
			
		||||
      onClose?.()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const formatTimestamp = (timestamp) => {
 | 
			
		||||
    return new Date(timestamp).toLocaleString('ja-JP', {
 | 
			
		||||
      month: 'short',
 | 
			
		||||
      day: 'numeric',
 | 
			
		||||
      hour: '2-digit',
 | 
			
		||||
      minute: '2-digit'
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const renderMessage = (entry, index) => (
 | 
			
		||||
    <div key={entry.id || index} className="chat-message">
 | 
			
		||||
      {/* ユーザーの質問 */}
 | 
			
		||||
      <div className="user-message">
 | 
			
		||||
        <div className="message-header">
 | 
			
		||||
          <div className="avatar">
 | 
			
		||||
            {entry.user?.avatar ? (
 | 
			
		||||
              <img src={entry.user.avatar} alt={entry.user.displayName} className="profile-avatar" />
 | 
			
		||||
            ) : (
 | 
			
		||||
              '👤'
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="user-info">
 | 
			
		||||
            <div className="display-name">{entry.user?.displayName || 'You'}</div>
 | 
			
		||||
            <div className="handle">@{entry.user?.handle || 'user'}</div>
 | 
			
		||||
            <div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="message-content">{entry.question}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {/* AIの回答 */}
 | 
			
		||||
      <div className="ai-message">
 | 
			
		||||
        <div className="message-header">
 | 
			
		||||
          <div className="avatar">
 | 
			
		||||
            {adminData?.profile?.avatar ? (
 | 
			
		||||
              <img src={adminData.profile.avatar} alt={adminData.profile.displayName} className="profile-avatar" />
 | 
			
		||||
            ) : (
 | 
			
		||||
              '🤖'
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="user-info">
 | 
			
		||||
            <div className="display-name">{adminData?.profile?.displayName || 'AI'}</div>
 | 
			
		||||
            <div className="handle">@{adminData?.profile?.handle || 'ai'}</div>
 | 
			
		||||
            <div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="message-content">{entry.answer}</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="ask-ai-container">
 | 
			
		||||
      <div className="ask-ai-header">
 | 
			
		||||
        <h3>Ask AI</h3>
 | 
			
		||||
        <div className="header-actions">
 | 
			
		||||
          <button onClick={clearChatHistory} className="clear-btn" title="履歴をクリア">
 | 
			
		||||
            🗑️
 | 
			
		||||
          </button>
 | 
			
		||||
          <button onClick={onClose} className="close-btn" title="閉じる">
 | 
			
		||||
            ✕
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="chat-container">
 | 
			
		||||
        {chatHistory.length === 0 && !loading ? (
 | 
			
		||||
          <div className="welcome-message">
 | 
			
		||||
            <div className="ai-message">
 | 
			
		||||
              <div className="message-header">
 | 
			
		||||
                <div className="avatar">
 | 
			
		||||
                  {adminData?.profile?.avatar ? (
 | 
			
		||||
                    <img src={adminData.profile.avatar} alt={adminData.profile.displayName} className="profile-avatar" />
 | 
			
		||||
                  ) : (
 | 
			
		||||
                    '🤖'
 | 
			
		||||
                  )}
 | 
			
		||||
                </div>
 | 
			
		||||
                <div className="user-info">
 | 
			
		||||
                  <div className="display-name">{adminData?.profile?.displayName || 'AI'}</div>
 | 
			
		||||
                  <div className="handle">@{adminData?.profile?.handle || 'ai'}</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="message-content">
 | 
			
		||||
                こんにちは!このブログの内容について何でも質問してください。記事の詳細や関連する話題について説明できます。
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        ) : (
 | 
			
		||||
          chatHistory.map(renderMessage)
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {loading && (
 | 
			
		||||
          <div className="ai-loading">
 | 
			
		||||
            <div className="message-header">
 | 
			
		||||
              <div className="avatar">🤖</div>
 | 
			
		||||
              <div className="user-info">
 | 
			
		||||
                <div className="display-name">考え中...</div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
            <LoadingSkeleton count={1} />
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {error && (
 | 
			
		||||
          <div className="error-message">
 | 
			
		||||
            <div className="message-content">
 | 
			
		||||
              エラー: {error}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <div ref={chatEndRef} />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <form onSubmit={handleSubmit} className="question-form">
 | 
			
		||||
        <div className="input-container">
 | 
			
		||||
          <textarea
 | 
			
		||||
            value={question}
 | 
			
		||||
            onChange={(e) => setQuestion(e.target.value)}
 | 
			
		||||
            onKeyDown={handleKeyDown}
 | 
			
		||||
            onCompositionStart={() => setIsComposing(true)}
 | 
			
		||||
            onCompositionEnd={() => setIsComposing(false)}
 | 
			
		||||
            placeholder="質問を入力してください..."
 | 
			
		||||
            rows={2}
 | 
			
		||||
            disabled={loading || !user}
 | 
			
		||||
            className="question-input"
 | 
			
		||||
          />
 | 
			
		||||
          <button
 | 
			
		||||
            type="submit"
 | 
			
		||||
            disabled={loading || !question.trim() || !user}
 | 
			
		||||
            className="send-btn"
 | 
			
		||||
          >
 | 
			
		||||
            {loading ? '⏳' : '📤'}
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
        {!user && (
 | 
			
		||||
          <div className="auth-notice">
 | 
			
		||||
            ログインしてください
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </form>
 | 
			
		||||
 | 
			
		||||
      <style jsx>{`
 | 
			
		||||
        .ask-ai-container {
 | 
			
		||||
          width: 100%;
 | 
			
		||||
          max-width: 600px;
 | 
			
		||||
          height: 500px;
 | 
			
		||||
          display: flex;
 | 
			
		||||
          flex-direction: column;
 | 
			
		||||
          border: 1px solid #ddd;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          background: white;
 | 
			
		||||
          overflow: hidden;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .ask-ai-header {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          justify-content: space-between;
 | 
			
		||||
          align-items: center;
 | 
			
		||||
          padding: 15px;
 | 
			
		||||
          border-bottom: 1px solid #eee;
 | 
			
		||||
          background: #f8f9fa;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .ask-ai-header h3 {
 | 
			
		||||
          margin: 0;
 | 
			
		||||
          color: #333;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .header-actions {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          gap: 8px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .clear-btn, .close-btn {
 | 
			
		||||
          background: none;
 | 
			
		||||
          border: none;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          padding: 5px;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          font-size: 14px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .clear-btn:hover, .close-btn:hover {
 | 
			
		||||
          background: #e9ecef;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .chat-container {
 | 
			
		||||
          flex: 1;
 | 
			
		||||
          overflow-y: auto;
 | 
			
		||||
          padding: 15px;
 | 
			
		||||
          display: flex;
 | 
			
		||||
          flex-direction: column;
 | 
			
		||||
          gap: 15px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .chat-message {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          flex-direction: column;
 | 
			
		||||
          gap: 10px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .user-message, .ai-message, .welcome-message {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          flex-direction: column;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .user-message {
 | 
			
		||||
          align-self: flex-end;
 | 
			
		||||
          max-width: 80%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .ai-message, .welcome-message {
 | 
			
		||||
          align-self: flex-start;
 | 
			
		||||
          max-width: 90%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .message-header {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          align-items: center;
 | 
			
		||||
          gap: 8px;
 | 
			
		||||
          margin-bottom: 5px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .avatar {
 | 
			
		||||
          width: 24px;
 | 
			
		||||
          height: 24px;
 | 
			
		||||
          border-radius: 50%;
 | 
			
		||||
          overflow: hidden;
 | 
			
		||||
          flex-shrink: 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .profile-avatar {
 | 
			
		||||
          width: 100%;
 | 
			
		||||
          height: 100%;
 | 
			
		||||
          object-fit: cover;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .user-info {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          flex-direction: column;
 | 
			
		||||
          gap: 2px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .display-name {
 | 
			
		||||
          font-weight: bold;
 | 
			
		||||
          font-size: 12px;
 | 
			
		||||
          color: #333;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .handle {
 | 
			
		||||
          font-size: 11px;
 | 
			
		||||
          color: #666;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .timestamp {
 | 
			
		||||
          font-size: 10px;
 | 
			
		||||
          color: #999;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .message-content {
 | 
			
		||||
          background: #f1f3f4;
 | 
			
		||||
          padding: 10px 12px;
 | 
			
		||||
          border-radius: 12px;
 | 
			
		||||
          font-size: 14px;
 | 
			
		||||
          line-height: 1.4;
 | 
			
		||||
          white-space: pre-wrap;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .user-message .message-content {
 | 
			
		||||
          background: #007bff;
 | 
			
		||||
          color: white;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .ai-message .message-content {
 | 
			
		||||
          background: #e9ecef;
 | 
			
		||||
          color: #333;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .ai-loading {
 | 
			
		||||
          align-self: flex-start;
 | 
			
		||||
          max-width: 90%;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .error-message {
 | 
			
		||||
          background: #f8d7da;
 | 
			
		||||
          color: #721c24;
 | 
			
		||||
          padding: 10px;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          border: 1px solid #f5c6cb;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .question-form {
 | 
			
		||||
          padding: 15px;
 | 
			
		||||
          border-top: 1px solid #eee;
 | 
			
		||||
          background: #f8f9fa;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .input-container {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          gap: 8px;
 | 
			
		||||
          align-items: end;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .question-input {
 | 
			
		||||
          flex: 1;
 | 
			
		||||
          padding: 8px 12px;
 | 
			
		||||
          border: 1px solid #ddd;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          font-size: 14px;
 | 
			
		||||
          resize: none;
 | 
			
		||||
          font-family: inherit;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .question-input:focus {
 | 
			
		||||
          outline: none;
 | 
			
		||||
          border-color: #007bff;
 | 
			
		||||
          box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .question-input:disabled {
 | 
			
		||||
          background: #e9ecef;
 | 
			
		||||
          cursor: not-allowed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .send-btn {
 | 
			
		||||
          background: #007bff;
 | 
			
		||||
          color: white;
 | 
			
		||||
          border: none;
 | 
			
		||||
          padding: 8px 12px;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          font-size: 16px;
 | 
			
		||||
          transition: background 0.2s;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .send-btn:hover:not(:disabled) {
 | 
			
		||||
          background: #0056b3;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .send-btn:disabled {
 | 
			
		||||
          background: #6c757d;
 | 
			
		||||
          cursor: not-allowed;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .auth-notice {
 | 
			
		||||
          text-align: center;
 | 
			
		||||
          font-size: 12px;
 | 
			
		||||
          color: #666;
 | 
			
		||||
          margin-top: 8px;
 | 
			
		||||
        }
 | 
			
		||||
      `}</style>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@@ -25,78 +25,53 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
 | 
			
		||||
 | 
			
		||||
  if (user) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="auth-status">
 | 
			
		||||
        <div>ログイン中: <strong>{user.handle}</strong></div>
 | 
			
		||||
        <button onClick={onLogout} className="logout-btn">
 | 
			
		||||
      <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
 | 
			
		||||
        {user.avatar && (
 | 
			
		||||
          <img 
 | 
			
		||||
            src={user.avatar} 
 | 
			
		||||
            alt="Profile" 
 | 
			
		||||
            className="avatar"
 | 
			
		||||
            style={{ width: '24px', height: '24px' }}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        <div>
 | 
			
		||||
          <div className="display-name" style={{ fontSize: '14px', fontWeight: '700' }}>
 | 
			
		||||
            {user.displayName}
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="handle" style={{ fontSize: '12px' }}>
 | 
			
		||||
            @{user.handle}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <button onClick={onLogout} className="btn btn-danger btn-sm">
 | 
			
		||||
          ログアウト
 | 
			
		||||
        </button>
 | 
			
		||||
        <style jsx>{`
 | 
			
		||||
          .auth-status {
 | 
			
		||||
            padding: 10px;
 | 
			
		||||
            border: 1px solid #ddd;
 | 
			
		||||
            border-radius: 5px;
 | 
			
		||||
            background: #f9f9f9;
 | 
			
		||||
          }
 | 
			
		||||
          .logout-btn {
 | 
			
		||||
            margin-top: 5px;
 | 
			
		||||
            padding: 5px 10px;
 | 
			
		||||
            background: #dc3545;
 | 
			
		||||
            color: white;
 | 
			
		||||
            border: none;
 | 
			
		||||
            border-radius: 3px;
 | 
			
		||||
            cursor: pointer;
 | 
			
		||||
          }
 | 
			
		||||
        `}</style>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="auth-form">
 | 
			
		||||
      <h3>OAuth認証</h3>
 | 
			
		||||
      <form onSubmit={handleSubmit}>
 | 
			
		||||
        <input
 | 
			
		||||
          type="text"
 | 
			
		||||
          value={handleInput}
 | 
			
		||||
          onChange={(e) => setHandleInput(e.target.value)}
 | 
			
		||||
          placeholder="Handle (e.g. your.handle.com)"
 | 
			
		||||
          disabled={isLoading}
 | 
			
		||||
          className="handle-input"
 | 
			
		||||
        />
 | 
			
		||||
        <button 
 | 
			
		||||
          type="submit" 
 | 
			
		||||
          disabled={isLoading || !handleInput.trim()}
 | 
			
		||||
          className="login-btn"
 | 
			
		||||
        >
 | 
			
		||||
          {isLoading ? 'ログイン中...' : 'ログイン'}
 | 
			
		||||
        </button>
 | 
			
		||||
      </form>
 | 
			
		||||
      <style jsx>{`
 | 
			
		||||
        .auth-form {
 | 
			
		||||
          padding: 10px;
 | 
			
		||||
          border: 1px solid #ddd;
 | 
			
		||||
          border-radius: 5px;
 | 
			
		||||
        }
 | 
			
		||||
        .handle-input {
 | 
			
		||||
          width: 200px;
 | 
			
		||||
          margin-right: 10px;
 | 
			
		||||
          padding: 5px;
 | 
			
		||||
          border: 1px solid #ccc;
 | 
			
		||||
          border-radius: 3px;
 | 
			
		||||
        }
 | 
			
		||||
        .login-btn {
 | 
			
		||||
          padding: 5px 10px;
 | 
			
		||||
          background: #007bff;
 | 
			
		||||
          color: white;
 | 
			
		||||
          border: none;
 | 
			
		||||
          border-radius: 3px;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
        }
 | 
			
		||||
        .login-btn:disabled {
 | 
			
		||||
          background: #ccc;
 | 
			
		||||
          cursor: not-allowed;
 | 
			
		||||
        }
 | 
			
		||||
      `}</style>
 | 
			
		||||
    <div className="auth-section search-bar-layout">
 | 
			
		||||
      <input
 | 
			
		||||
        type="text"
 | 
			
		||||
        value={handleInput}
 | 
			
		||||
        onChange={(e) => setHandleInput(e.target.value)}
 | 
			
		||||
        placeholder="your.handle.com"
 | 
			
		||||
        disabled={isLoading}
 | 
			
		||||
        className="handle-input"
 | 
			
		||||
        onKeyPress={(e) => {
 | 
			
		||||
          if (e.key === 'Enter') {
 | 
			
		||||
            handleSubmit(e)
 | 
			
		||||
          }
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
      <button 
 | 
			
		||||
        type="button"
 | 
			
		||||
        onClick={handleSubmit}
 | 
			
		||||
        disabled={isLoading || !handleInput.trim()}
 | 
			
		||||
        className="auth-button"
 | 
			
		||||
      >
 | 
			
		||||
        {isLoading ? '認証中...' : <i className="fab fa-bluesky"></i>}
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										234
									
								
								oauth_new/src/components/Avatar.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								oauth_new/src/components/Avatar.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,234 @@
 | 
			
		||||
import React, { useState, useEffect } from 'react'
 | 
			
		||||
import { getAvatar } from '../utils/avatar.js'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Avatar component with intelligent fallback
 | 
			
		||||
 * 
 | 
			
		||||
 * @param {Object} props
 | 
			
		||||
 * @param {Object} props.record - Record object containing avatar data
 | 
			
		||||
 * @param {string} props.handle - User handle
 | 
			
		||||
 * @param {string} props.did - User DID
 | 
			
		||||
 * @param {string} props.alt - Alt text for image
 | 
			
		||||
 * @param {string} props.className - CSS class name
 | 
			
		||||
 * @param {number} props.size - Avatar size in pixels
 | 
			
		||||
 * @param {boolean} props.showFallback - Show fallback UI if no avatar
 | 
			
		||||
 * @param {Function} props.onLoad - Callback when avatar loads
 | 
			
		||||
 * @param {Function} props.onError - Callback when avatar fails to load
 | 
			
		||||
 */
 | 
			
		||||
export default function Avatar({ 
 | 
			
		||||
  record, 
 | 
			
		||||
  handle, 
 | 
			
		||||
  did, 
 | 
			
		||||
  alt = 'avatar',
 | 
			
		||||
  className = 'avatar',
 | 
			
		||||
  size = 40,
 | 
			
		||||
  showFallback = true,
 | 
			
		||||
  onLoad,
 | 
			
		||||
  onError
 | 
			
		||||
}) {
 | 
			
		||||
  const [avatarUrl, setAvatarUrl] = useState(null)
 | 
			
		||||
  const [loading, setLoading] = useState(true)
 | 
			
		||||
  const [error, setError] = useState(null)
 | 
			
		||||
  const [imageError, setImageError] = useState(false)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    let cancelled = false
 | 
			
		||||
 | 
			
		||||
    async function loadAvatar() {
 | 
			
		||||
      try {
 | 
			
		||||
        setLoading(true)
 | 
			
		||||
        setError(null)
 | 
			
		||||
        setImageError(false)
 | 
			
		||||
 | 
			
		||||
        const url = await getAvatar({ record, handle, did })
 | 
			
		||||
        
 | 
			
		||||
        if (!cancelled) {
 | 
			
		||||
          setAvatarUrl(url)
 | 
			
		||||
          setLoading(false)
 | 
			
		||||
        }
 | 
			
		||||
      } catch (err) {
 | 
			
		||||
        if (!cancelled) {
 | 
			
		||||
          setError(err.message)
 | 
			
		||||
          setLoading(false)
 | 
			
		||||
          if (onError) onError(err)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    loadAvatar()
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      cancelled = true
 | 
			
		||||
    }
 | 
			
		||||
  }, [record, handle, did])
 | 
			
		||||
 | 
			
		||||
  const handleImageError = async () => {
 | 
			
		||||
    setImageError(true)
 | 
			
		||||
    if (onError) onError(new Error('Image failed to load'))
 | 
			
		||||
    
 | 
			
		||||
    // Try to fetch fresh avatar if the current one failed
 | 
			
		||||
    if (!loading && avatarUrl) {
 | 
			
		||||
      try {
 | 
			
		||||
        const freshUrl = await getAvatar({ handle, did, forceFresh: true })
 | 
			
		||||
        if (freshUrl && freshUrl !== avatarUrl) {
 | 
			
		||||
          setAvatarUrl(freshUrl)
 | 
			
		||||
          setImageError(false)
 | 
			
		||||
        }
 | 
			
		||||
      } catch {
 | 
			
		||||
        // Ignore errors in retry
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleImageLoad = () => {
 | 
			
		||||
    setImageError(false)
 | 
			
		||||
    if (onLoad) onLoad()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Determine what to render
 | 
			
		||||
  if (loading) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div 
 | 
			
		||||
        className={`${className} avatar-loading`}
 | 
			
		||||
        style={{ width: size, height: size }}
 | 
			
		||||
        aria-label="Loading avatar..."
 | 
			
		||||
      />
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (error || !avatarUrl || imageError) {
 | 
			
		||||
    if (!showFallback) return null
 | 
			
		||||
    
 | 
			
		||||
    // Fallback avatar
 | 
			
		||||
    const initial = (handle || 'U')[0].toUpperCase()
 | 
			
		||||
    return (
 | 
			
		||||
      <div 
 | 
			
		||||
        className={`${className} avatar-fallback`}
 | 
			
		||||
        style={{ 
 | 
			
		||||
          width: size, 
 | 
			
		||||
          height: size,
 | 
			
		||||
          display: 'flex',
 | 
			
		||||
          alignItems: 'center',
 | 
			
		||||
          justifyContent: 'center',
 | 
			
		||||
          backgroundColor: '#e1e1e1',
 | 
			
		||||
          borderRadius: '50%',
 | 
			
		||||
          fontSize: size * 0.4
 | 
			
		||||
        }}
 | 
			
		||||
        aria-label={alt}
 | 
			
		||||
      >
 | 
			
		||||
        {initial}
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <img
 | 
			
		||||
      src={avatarUrl}
 | 
			
		||||
      alt={alt}
 | 
			
		||||
      className={className}
 | 
			
		||||
      style={{ width: size, height: size }}
 | 
			
		||||
      onError={handleImageError}
 | 
			
		||||
      onLoad={handleImageLoad}
 | 
			
		||||
      loading="lazy"
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Avatar with hover card showing user info
 | 
			
		||||
 */
 | 
			
		||||
export function AvatarWithCard({ 
 | 
			
		||||
  record, 
 | 
			
		||||
  handle, 
 | 
			
		||||
  did,
 | 
			
		||||
  displayName,
 | 
			
		||||
  apiConfig,
 | 
			
		||||
  ...avatarProps 
 | 
			
		||||
}) {
 | 
			
		||||
  const [showCard, setShowCard] = useState(false)
 | 
			
		||||
  
 | 
			
		||||
  return (
 | 
			
		||||
    <div 
 | 
			
		||||
      className="avatar-container"
 | 
			
		||||
      onMouseEnter={() => setShowCard(true)}
 | 
			
		||||
      onMouseLeave={() => setShowCard(false)}
 | 
			
		||||
    >
 | 
			
		||||
      <Avatar 
 | 
			
		||||
        record={record}
 | 
			
		||||
        handle={handle}
 | 
			
		||||
        did={did}
 | 
			
		||||
        {...avatarProps}
 | 
			
		||||
      />
 | 
			
		||||
      
 | 
			
		||||
      {showCard && (
 | 
			
		||||
        <div className="avatar-card">
 | 
			
		||||
          <Avatar 
 | 
			
		||||
            record={record}
 | 
			
		||||
            handle={handle}
 | 
			
		||||
            did={did}
 | 
			
		||||
            size={80}
 | 
			
		||||
            className="avatar-card-image"
 | 
			
		||||
          />
 | 
			
		||||
          <div className="avatar-card-info">
 | 
			
		||||
            <div className="avatar-card-name">{displayName || handle}</div>
 | 
			
		||||
            <a 
 | 
			
		||||
              href={`${apiConfig?.web || 'https://bsky.app'}/profile/${did || handle}`}
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
              className="avatar-card-handle"
 | 
			
		||||
            >
 | 
			
		||||
              @{handle}
 | 
			
		||||
            </a>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Avatar list component for displaying multiple avatars
 | 
			
		||||
 */
 | 
			
		||||
export function AvatarList({ users, maxDisplay = 5, size = 30 }) {
 | 
			
		||||
  const displayUsers = users.slice(0, maxDisplay)
 | 
			
		||||
  const remainingCount = Math.max(0, users.length - maxDisplay)
 | 
			
		||||
  
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="avatar-list">
 | 
			
		||||
      {displayUsers.map((user, index) => (
 | 
			
		||||
        <div 
 | 
			
		||||
          key={user.handle || index} 
 | 
			
		||||
          className="avatar-list-item"
 | 
			
		||||
          style={{ marginLeft: index > 0 ? -10 : 0, zIndex: displayUsers.length - index }}
 | 
			
		||||
        >
 | 
			
		||||
          <Avatar
 | 
			
		||||
            handle={user.handle}
 | 
			
		||||
            did={user.did}
 | 
			
		||||
            record={user.record}
 | 
			
		||||
            size={size}
 | 
			
		||||
            showFallback={true}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
      ))}
 | 
			
		||||
      {remainingCount > 0 && (
 | 
			
		||||
        <div 
 | 
			
		||||
          className="avatar-list-more"
 | 
			
		||||
          style={{ 
 | 
			
		||||
            width: size, 
 | 
			
		||||
            height: size,
 | 
			
		||||
            marginLeft: -10,
 | 
			
		||||
            display: 'flex',
 | 
			
		||||
            alignItems: 'center',
 | 
			
		||||
            justifyContent: 'center',
 | 
			
		||||
            backgroundColor: '#666',
 | 
			
		||||
            color: '#fff',
 | 
			
		||||
            borderRadius: '50%',
 | 
			
		||||
            fontSize: size * 0.4
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          +{remainingCount}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										103
									
								
								oauth_new/src/components/AvatarImage.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								oauth_new/src/components/AvatarImage.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,103 @@
 | 
			
		||||
import React, { useState, useEffect } from 'react'
 | 
			
		||||
import { getValidAvatar } from '../utils/avatarFetcher.js'
 | 
			
		||||
import { logger } from '../utils/logger.js'
 | 
			
		||||
 | 
			
		||||
export default function AvatarImage({ record, size = 40, className = "avatar" }) {
 | 
			
		||||
  const [avatarUrl, setAvatarUrl] = useState(record?.value?.author?.avatar)
 | 
			
		||||
  const [loading, setLoading] = useState(false)
 | 
			
		||||
  const [error, setError] = useState(false)
 | 
			
		||||
 | 
			
		||||
  const author = record?.value?.author
 | 
			
		||||
  const handle = author?.handle
 | 
			
		||||
  const displayName = author?.displayName || handle
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    // record内のavatarが無い、またはエラーの場合に新しく取得
 | 
			
		||||
    if (!avatarUrl || error) {
 | 
			
		||||
      fetchValidAvatar()
 | 
			
		||||
    }
 | 
			
		||||
  }, [record, error])
 | 
			
		||||
 | 
			
		||||
  const fetchValidAvatar = async () => {
 | 
			
		||||
    if (!record || loading) return
 | 
			
		||||
 | 
			
		||||
    setLoading(true)
 | 
			
		||||
    try {
 | 
			
		||||
      const validAvatar = await getValidAvatar(record)
 | 
			
		||||
      setAvatarUrl(validAvatar)
 | 
			
		||||
      setError(false)
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      logger.error('Failed to fetch valid avatar:', err)
 | 
			
		||||
      setError(true)
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleImageError = () => {
 | 
			
		||||
    setError(true)
 | 
			
		||||
    // エラー時に再取得を試行
 | 
			
		||||
    fetchValidAvatar()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleImageLoad = () => {
 | 
			
		||||
    setError(false)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // ローディング中のスケルトン
 | 
			
		||||
  if (loading) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div 
 | 
			
		||||
        className={`${className} avatar-loading`}
 | 
			
		||||
        style={{
 | 
			
		||||
          width: size,
 | 
			
		||||
          height: size,
 | 
			
		||||
          backgroundColor: '#f0f0f0',
 | 
			
		||||
          borderRadius: '50%',
 | 
			
		||||
          animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite'
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // avatar URLがある場合
 | 
			
		||||
  if (avatarUrl && !error) {
 | 
			
		||||
    return (
 | 
			
		||||
      <img 
 | 
			
		||||
        src={avatarUrl}
 | 
			
		||||
        alt={`${displayName} avatar`}
 | 
			
		||||
        className={className}
 | 
			
		||||
        style={{
 | 
			
		||||
          width: size,
 | 
			
		||||
          height: size,
 | 
			
		||||
          borderRadius: '50%',
 | 
			
		||||
          objectFit: 'cover'
 | 
			
		||||
        }}
 | 
			
		||||
        onError={handleImageError}
 | 
			
		||||
        onLoad={handleImageLoad}
 | 
			
		||||
      />
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // フォールバック: 初期文字のアバター
 | 
			
		||||
  const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
 | 
			
		||||
  return (
 | 
			
		||||
    <div 
 | 
			
		||||
      className={`${className} avatar-fallback`}
 | 
			
		||||
      style={{
 | 
			
		||||
        width: size,
 | 
			
		||||
        height: size,
 | 
			
		||||
        borderRadius: '50%',
 | 
			
		||||
        backgroundColor: '#ddd',
 | 
			
		||||
        display: 'flex',
 | 
			
		||||
        alignItems: 'center',
 | 
			
		||||
        justifyContent: 'center',
 | 
			
		||||
        fontSize: size * 0.4,
 | 
			
		||||
        fontWeight: 'bold',
 | 
			
		||||
        color: '#666'
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
      {initial}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										203
									
								
								oauth_new/src/components/AvatarTest.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										203
									
								
								oauth_new/src/components/AvatarTest.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,203 @@
 | 
			
		||||
import React, { useState, useEffect } from 'react'
 | 
			
		||||
import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx'
 | 
			
		||||
import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Test component to demonstrate avatar functionality
 | 
			
		||||
 */
 | 
			
		||||
export default function AvatarTest() {
 | 
			
		||||
  const [testResults, setTestResults] = useState({})
 | 
			
		||||
  const [loading, setLoading] = useState(false)
 | 
			
		||||
 | 
			
		||||
  // Test data
 | 
			
		||||
  const testUsers = [
 | 
			
		||||
    { handle: 'syui.ai', did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn' },
 | 
			
		||||
    { handle: 'ai.syui.ai', did: 'did:plc:4hqjfn7m6n5hno3doamuhgef' },
 | 
			
		||||
    { handle: 'yui.syui.ai', did: 'did:plc:6qyecktefllvenje24fcxnie' }
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  const sampleRecord = {
 | 
			
		||||
    value: {
 | 
			
		||||
      author: {
 | 
			
		||||
        handle: 'syui.ai',
 | 
			
		||||
        did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
 | 
			
		||||
        displayName: 'syui',
 | 
			
		||||
        avatar: 'https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg'
 | 
			
		||||
      },
 | 
			
		||||
      text: 'Test message',
 | 
			
		||||
      createdAt: new Date().toISOString()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Test functions
 | 
			
		||||
  const testGetAvatar = async () => {
 | 
			
		||||
    setLoading(true)
 | 
			
		||||
    try {
 | 
			
		||||
      const results = {}
 | 
			
		||||
      
 | 
			
		||||
      // Test with record
 | 
			
		||||
      results.fromRecord = await getAvatar({ record: sampleRecord })
 | 
			
		||||
      
 | 
			
		||||
      // Test with handle only
 | 
			
		||||
      results.fromHandle = await getAvatar({ handle: 'syui.ai' })
 | 
			
		||||
      
 | 
			
		||||
      // Test with broken record (force fresh fetch)
 | 
			
		||||
      const brokenRecord = {
 | 
			
		||||
        ...sampleRecord,
 | 
			
		||||
        value: {
 | 
			
		||||
          ...sampleRecord.value,
 | 
			
		||||
          author: {
 | 
			
		||||
            ...sampleRecord.value.author,
 | 
			
		||||
            avatar: 'https://broken-url.com/avatar.jpg'
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      results.brokenRecord = await getAvatar({ record: brokenRecord })
 | 
			
		||||
      
 | 
			
		||||
      // Test non-existent user
 | 
			
		||||
      try {
 | 
			
		||||
        results.nonExistent = await getAvatar({ handle: 'nonexistent.user' })
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        results.nonExistent = `Error: ${error.message}`
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      setTestResults(results)
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Test failed:', error)
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const testBatchFetch = async () => {
 | 
			
		||||
    setLoading(true)
 | 
			
		||||
    try {
 | 
			
		||||
      const avatarMap = await batchFetchAvatars(testUsers)
 | 
			
		||||
      setTestResults(prev => ({
 | 
			
		||||
        ...prev,
 | 
			
		||||
        batchResults: Object.fromEntries(avatarMap)
 | 
			
		||||
      }))
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Batch test failed:', error)
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const testPrefetch = async () => {
 | 
			
		||||
    setLoading(true)
 | 
			
		||||
    try {
 | 
			
		||||
      await prefetchAvatar('syui.ai')
 | 
			
		||||
      const cachedAvatar = await getAvatar({ handle: 'syui.ai' })
 | 
			
		||||
      setTestResults(prev => ({
 | 
			
		||||
        ...prev,
 | 
			
		||||
        prefetchResult: cachedAvatar
 | 
			
		||||
      }))
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('Prefetch test failed:', error)
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="avatar-test-container">
 | 
			
		||||
      <div className="card">
 | 
			
		||||
        <div className="card-header">
 | 
			
		||||
          <h2>Avatar System Test</h2>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="card-content">
 | 
			
		||||
          
 | 
			
		||||
          {/* Basic Avatar Examples */}
 | 
			
		||||
          <section className="test-section">
 | 
			
		||||
            <h3>Basic Avatar Examples</h3>
 | 
			
		||||
            <div className="avatar-examples">
 | 
			
		||||
              <div className="avatar-example">
 | 
			
		||||
                <h4>From Record</h4>
 | 
			
		||||
                <Avatar record={sampleRecord} size={60} />
 | 
			
		||||
              </div>
 | 
			
		||||
              
 | 
			
		||||
              <div className="avatar-example">
 | 
			
		||||
                <h4>From Handle</h4>
 | 
			
		||||
                <Avatar handle="syui.ai" size={60} />
 | 
			
		||||
              </div>
 | 
			
		||||
              
 | 
			
		||||
              <div className="avatar-example">
 | 
			
		||||
                <h4>With Fallback</h4>
 | 
			
		||||
                <Avatar handle="nonexistent.user" size={60} />
 | 
			
		||||
              </div>
 | 
			
		||||
              
 | 
			
		||||
              <div className="avatar-example">
 | 
			
		||||
                <h4>Loading State</h4>
 | 
			
		||||
                <div className="avatar-loading" style={{ width: 60, height: 60 }} />
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </section>
 | 
			
		||||
 | 
			
		||||
          {/* Avatar with Card */}
 | 
			
		||||
          <section className="test-section">
 | 
			
		||||
            <h3>Avatar with Hover Card</h3>
 | 
			
		||||
            <div className="avatar-examples">
 | 
			
		||||
              <AvatarWithCard
 | 
			
		||||
                record={sampleRecord}
 | 
			
		||||
                displayName="syui"
 | 
			
		||||
                apiConfig={{ web: 'https://bsky.app' }}
 | 
			
		||||
                size={60}
 | 
			
		||||
              />
 | 
			
		||||
              <p>Hover over the avatar to see the card</p>
 | 
			
		||||
            </div>
 | 
			
		||||
          </section>
 | 
			
		||||
 | 
			
		||||
          {/* Avatar List */}
 | 
			
		||||
          <section className="test-section">
 | 
			
		||||
            <h3>Avatar List</h3>
 | 
			
		||||
            <AvatarList users={testUsers} maxDisplay={3} size={40} />
 | 
			
		||||
          </section>
 | 
			
		||||
 | 
			
		||||
          {/* Test Controls */}
 | 
			
		||||
          <section className="test-section">
 | 
			
		||||
            <h3>Test Functions</h3>
 | 
			
		||||
            <div className="test-controls">
 | 
			
		||||
              <button 
 | 
			
		||||
                onClick={testGetAvatar} 
 | 
			
		||||
                disabled={loading}
 | 
			
		||||
                className="btn btn-primary"
 | 
			
		||||
              >
 | 
			
		||||
                Test getAvatar()
 | 
			
		||||
              </button>
 | 
			
		||||
              
 | 
			
		||||
              <button 
 | 
			
		||||
                onClick={testBatchFetch} 
 | 
			
		||||
                disabled={loading}
 | 
			
		||||
                className="btn btn-primary"
 | 
			
		||||
              >
 | 
			
		||||
                Test Batch Fetch
 | 
			
		||||
              </button>
 | 
			
		||||
              
 | 
			
		||||
              <button 
 | 
			
		||||
                onClick={testPrefetch} 
 | 
			
		||||
                disabled={loading}
 | 
			
		||||
                className="btn btn-primary"
 | 
			
		||||
              >
 | 
			
		||||
                Test Prefetch
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </section>
 | 
			
		||||
 | 
			
		||||
          {/* Test Results */}
 | 
			
		||||
          {Object.keys(testResults).length > 0 && (
 | 
			
		||||
            <section className="test-section">
 | 
			
		||||
              <h3>Test Results</h3>
 | 
			
		||||
              <div className="json-display">
 | 
			
		||||
                <pre className="json-content">
 | 
			
		||||
                  {JSON.stringify(testResults, null, 2)}
 | 
			
		||||
                </pre>
 | 
			
		||||
              </div>
 | 
			
		||||
            </section>
 | 
			
		||||
          )}
 | 
			
		||||
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										246
									
								
								oauth_new/src/components/AvatarTestPanel.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										246
									
								
								oauth_new/src/components/AvatarTestPanel.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,246 @@
 | 
			
		||||
import React, { useState } from 'react'
 | 
			
		||||
import AvatarImage from './AvatarImage.jsx'
 | 
			
		||||
import { getValidAvatar, clearAvatarCache, getAvatarCacheStats } from '../utils/avatarFetcher.js'
 | 
			
		||||
 | 
			
		||||
export default function AvatarTestPanel() {
 | 
			
		||||
  const [testHandle, setTestHandle] = useState('ai.syui.ai')
 | 
			
		||||
  const [testResult, setTestResult] = useState(null)
 | 
			
		||||
  const [loading, setLoading] = useState(false)
 | 
			
		||||
  const [cacheStats, setCacheStats] = useState(null)
 | 
			
		||||
 | 
			
		||||
  // ダミーレコードを作成(実際の投稿したレコード形式)
 | 
			
		||||
  const createTestRecord = (handle, brokenAvatar = false) => ({
 | 
			
		||||
    value: {
 | 
			
		||||
      author: {
 | 
			
		||||
        did: null, // DIDはnullにして、handleから取得させる
 | 
			
		||||
        handle: handle,
 | 
			
		||||
        displayName: "Test User",
 | 
			
		||||
        avatar: brokenAvatar ? "https://broken.example.com/avatar.jpg" : null
 | 
			
		||||
      },
 | 
			
		||||
      text: "テストコメント",
 | 
			
		||||
      createdAt: new Date().toISOString()
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const testAvatarFetch = async (useBrokenAvatar = false) => {
 | 
			
		||||
    setLoading(true)
 | 
			
		||||
    setTestResult(null)
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const testRecord = createTestRecord(testHandle, useBrokenAvatar)
 | 
			
		||||
      const avatarUrl = await getValidAvatar(testRecord)
 | 
			
		||||
      
 | 
			
		||||
      setTestResult({
 | 
			
		||||
        success: true,
 | 
			
		||||
        avatarUrl,
 | 
			
		||||
        handle: testHandle,
 | 
			
		||||
        brokenTest: useBrokenAvatar,
 | 
			
		||||
        timestamp: new Date().toISOString()
 | 
			
		||||
      })
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      setTestResult({
 | 
			
		||||
        success: false,
 | 
			
		||||
        error: error.message,
 | 
			
		||||
        handle: testHandle,
 | 
			
		||||
        brokenTest: useBrokenAvatar
 | 
			
		||||
      })
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleClearCache = () => {
 | 
			
		||||
    clearAvatarCache()
 | 
			
		||||
    setCacheStats(null)
 | 
			
		||||
    alert('Avatar cache cleared!')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleShowCacheStats = () => {
 | 
			
		||||
    const stats = getAvatarCacheStats()
 | 
			
		||||
    setCacheStats(stats)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="test-ui">
 | 
			
		||||
      <h2>🖼️ Avatar Test Panel</h2>
 | 
			
		||||
      <p className="description">
 | 
			
		||||
        Avatar取得システムのテスト。投稿済みのdummy recordを使用してavatar取得処理を確認できます。
 | 
			
		||||
      </p>
 | 
			
		||||
 | 
			
		||||
      <div className="form-group">
 | 
			
		||||
        <label htmlFor="test-handle">Test Handle:</label>
 | 
			
		||||
        <input
 | 
			
		||||
          id="test-handle"
 | 
			
		||||
          type="text"
 | 
			
		||||
          value={testHandle}
 | 
			
		||||
          onChange={(e) => setTestHandle(e.target.value)}
 | 
			
		||||
          placeholder="ai.syui.ai"
 | 
			
		||||
          disabled={loading}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="form-actions">
 | 
			
		||||
        <button
 | 
			
		||||
          onClick={() => testAvatarFetch(false)}
 | 
			
		||||
          disabled={loading || !testHandle.trim()}
 | 
			
		||||
          className="btn btn-primary"
 | 
			
		||||
        >
 | 
			
		||||
          {loading ? '⏳ Testing...' : '🔄 Test Avatar Fetch'}
 | 
			
		||||
        </button>
 | 
			
		||||
 | 
			
		||||
        <button
 | 
			
		||||
          onClick={() => testAvatarFetch(true)}
 | 
			
		||||
          disabled={loading || !testHandle.trim()}
 | 
			
		||||
          className="btn btn-outline"
 | 
			
		||||
        >
 | 
			
		||||
          {loading ? '⏳ Testing...' : '💥 Test Broken Avatar'}
 | 
			
		||||
        </button>
 | 
			
		||||
 | 
			
		||||
        <button
 | 
			
		||||
          onClick={handleClearCache}
 | 
			
		||||
          disabled={loading}
 | 
			
		||||
          className="btn btn-danger btn-sm"
 | 
			
		||||
        >
 | 
			
		||||
          🗑️ Clear Cache
 | 
			
		||||
        </button>
 | 
			
		||||
 | 
			
		||||
        <button
 | 
			
		||||
          onClick={handleShowCacheStats}
 | 
			
		||||
          disabled={loading}
 | 
			
		||||
          className="btn btn-outline btn-sm"
 | 
			
		||||
        >
 | 
			
		||||
          📊 Cache Stats
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {testResult && (
 | 
			
		||||
        <div className="test-result">
 | 
			
		||||
          <h3>Test Result:</h3>
 | 
			
		||||
          {testResult.success ? (
 | 
			
		||||
            <div className="success-message">
 | 
			
		||||
              ✅ Avatar fetched successfully!
 | 
			
		||||
              <div className="result-details">
 | 
			
		||||
                <p><strong>Handle:</strong> {testResult.handle}</p>
 | 
			
		||||
                <p><strong>Broken Test:</strong> {testResult.brokenTest ? 'Yes' : 'No'}</p>
 | 
			
		||||
                <p><strong>Avatar URL:</strong> {testResult.avatarUrl || 'None'}</p>
 | 
			
		||||
                <p><strong>Timestamp:</strong> {testResult.timestamp}</p>
 | 
			
		||||
                
 | 
			
		||||
                {testResult.avatarUrl && (
 | 
			
		||||
                  <div className="avatar-preview">
 | 
			
		||||
                    <p><strong>Preview:</strong></p>
 | 
			
		||||
                    <img 
 | 
			
		||||
                      src={testResult.avatarUrl} 
 | 
			
		||||
                      alt="Avatar preview" 
 | 
			
		||||
                      style={{
 | 
			
		||||
                        width: 60,
 | 
			
		||||
                        height: 60,
 | 
			
		||||
                        borderRadius: '50%',
 | 
			
		||||
                        objectFit: 'cover',
 | 
			
		||||
                        border: '2px solid #ddd'
 | 
			
		||||
                      }}
 | 
			
		||||
                    />
 | 
			
		||||
                  </div>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <div className="error-message">
 | 
			
		||||
              ❌ Test failed: {testResult.error}
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {cacheStats && (
 | 
			
		||||
        <div className="cache-stats">
 | 
			
		||||
          <h3>Cache Statistics:</h3>
 | 
			
		||||
          <p><strong>Entries:</strong> {cacheStats.size}</p>
 | 
			
		||||
          {cacheStats.entries.length > 0 && (
 | 
			
		||||
            <div className="cache-entries">
 | 
			
		||||
              <h4>Cached Avatars:</h4>
 | 
			
		||||
              {cacheStats.entries.map((entry, i) => (
 | 
			
		||||
                <div key={i} className="cache-entry">
 | 
			
		||||
                  <p><strong>Key:</strong> {entry.key}</p>
 | 
			
		||||
                  <p><strong>Age:</strong> {Math.floor(entry.age / 1000)}s</p>
 | 
			
		||||
                  <p><strong>Profile:</strong> {entry.profile?.displayName} (@{entry.profile?.handle})</p>
 | 
			
		||||
                  {entry.avatar && (
 | 
			
		||||
                    <img 
 | 
			
		||||
                      src={entry.avatar} 
 | 
			
		||||
                      alt="Cached avatar" 
 | 
			
		||||
                      style={{ width: 30, height: 30, borderRadius: '50%' }}
 | 
			
		||||
                    />
 | 
			
		||||
                  )}
 | 
			
		||||
                </div>
 | 
			
		||||
              ))}
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <div className="live-demo">
 | 
			
		||||
        <h3>Live Avatar Component Demo:</h3>
 | 
			
		||||
        <p>実際のAvatarImageコンポーネントの動作確認:</p>
 | 
			
		||||
        <div style={{ display: 'flex', gap: '16px', alignItems: 'center', marginTop: '12px' }}>
 | 
			
		||||
          <AvatarImage record={createTestRecord(testHandle, false)} size={40} />
 | 
			
		||||
          <span>Normal avatar test</span>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div style={{ display: 'flex', gap: '16px', alignItems: 'center', marginTop: '12px' }}>
 | 
			
		||||
          <AvatarImage record={createTestRecord(testHandle, true)} size={40} />
 | 
			
		||||
          <span>Broken avatar test (should fetch fresh)</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <style jsx>{`
 | 
			
		||||
        .test-result {
 | 
			
		||||
          margin-top: 20px;
 | 
			
		||||
          padding: 16px;
 | 
			
		||||
          border: 1px solid #ddd;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          background: #f9f9f9;
 | 
			
		||||
        }
 | 
			
		||||
        .result-details {
 | 
			
		||||
          margin-top: 12px;
 | 
			
		||||
          font-size: 14px;
 | 
			
		||||
        }
 | 
			
		||||
        .result-details p {
 | 
			
		||||
          margin: 4px 0;
 | 
			
		||||
        }
 | 
			
		||||
        .avatar-preview {
 | 
			
		||||
          margin-top: 12px;
 | 
			
		||||
          padding: 12px;
 | 
			
		||||
          border: 1px solid #eee;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          background: white;
 | 
			
		||||
        }
 | 
			
		||||
        .cache-stats {
 | 
			
		||||
          margin-top: 20px;
 | 
			
		||||
          padding: 16px;
 | 
			
		||||
          border: 1px solid #ddd;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          background: #f0f8ff;
 | 
			
		||||
        }
 | 
			
		||||
        .cache-entries {
 | 
			
		||||
          margin-top: 12px;
 | 
			
		||||
        }
 | 
			
		||||
        .cache-entry {
 | 
			
		||||
          padding: 8px;
 | 
			
		||||
          margin: 8px 0;
 | 
			
		||||
          border: 1px solid #ddd;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          background: white;
 | 
			
		||||
          font-size: 12px;
 | 
			
		||||
        }
 | 
			
		||||
        .cache-entry p {
 | 
			
		||||
          margin: 2px 0;
 | 
			
		||||
        }
 | 
			
		||||
        .live-demo {
 | 
			
		||||
          margin-top: 20px;
 | 
			
		||||
          padding: 16px;
 | 
			
		||||
          border: 1px solid #ddd;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          background: #f8f9fa;
 | 
			
		||||
        }
 | 
			
		||||
      `}</style>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import React, { useState } from 'react'
 | 
			
		||||
import { atproto } from '../api/atproto.js'
 | 
			
		||||
import { atproto, collections } from '../api/atproto.js'
 | 
			
		||||
import { env } from '../config/env.js'
 | 
			
		||||
 | 
			
		||||
export default function CommentForm({ user, agent, onCommentPosted }) {
 | 
			
		||||
@@ -16,34 +16,43 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
 | 
			
		||||
    setError(null)
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      // Create ai.syui.log record structure
 | 
			
		||||
      const currentUrl = url.trim()
 | 
			
		||||
      const timestamp = new Date().toISOString()
 | 
			
		||||
      
 | 
			
		||||
      // Create ai.syui.log record structure (new unified format)
 | 
			
		||||
      const record = {
 | 
			
		||||
        repo: user.did,
 | 
			
		||||
        collection: env.collection,
 | 
			
		||||
        rkey: `comment-${Date.now()}`,
 | 
			
		||||
        record: {
 | 
			
		||||
          $type: env.collection,
 | 
			
		||||
          url: url.trim(),
 | 
			
		||||
          comments: [
 | 
			
		||||
            {
 | 
			
		||||
              url: url.trim(),
 | 
			
		||||
              text: text.trim(),
 | 
			
		||||
              author: {
 | 
			
		||||
                did: user.did,
 | 
			
		||||
                handle: user.handle,
 | 
			
		||||
                displayName: user.displayName,
 | 
			
		||||
                avatar: user.avatar
 | 
			
		||||
              },
 | 
			
		||||
              createdAt: new Date().toISOString()
 | 
			
		||||
            }
 | 
			
		||||
          ],
 | 
			
		||||
          createdAt: new Date().toISOString()
 | 
			
		||||
          url: currentUrl, // Keep for backward compatibility
 | 
			
		||||
          post: {
 | 
			
		||||
            url: currentUrl,
 | 
			
		||||
            date: timestamp,
 | 
			
		||||
            slug: new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
 | 
			
		||||
            tags: [],
 | 
			
		||||
            title: document.title || 'Comment',
 | 
			
		||||
            language: 'ja'
 | 
			
		||||
          },
 | 
			
		||||
          text: text.trim(),
 | 
			
		||||
          type: 'comment',
 | 
			
		||||
          author: {
 | 
			
		||||
            did: user.did,
 | 
			
		||||
            handle: user.handle,
 | 
			
		||||
            displayName: user.displayName,
 | 
			
		||||
            avatar: user.avatar
 | 
			
		||||
          },
 | 
			
		||||
          createdAt: timestamp
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Post the record
 | 
			
		||||
      await atproto.putRecord(null, record, agent)
 | 
			
		||||
 | 
			
		||||
      // キャッシュを無効化
 | 
			
		||||
      collections.invalidateCache(env.collection)
 | 
			
		||||
 | 
			
		||||
      // Clear form
 | 
			
		||||
      setText('')
 | 
			
		||||
      setUrl('')
 | 
			
		||||
@@ -62,14 +71,18 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
 | 
			
		||||
 | 
			
		||||
  if (!user) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="comment-form-placeholder">
 | 
			
		||||
      <div style={{ 
 | 
			
		||||
        textAlign: 'center', 
 | 
			
		||||
        padding: '40px', 
 | 
			
		||||
        color: 'var(--text-secondary)'
 | 
			
		||||
      }}>
 | 
			
		||||
        <p>ログインしてコメントを投稿</p>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="comment-form">
 | 
			
		||||
    <div>
 | 
			
		||||
      <h3>コメントを投稿</h3>
 | 
			
		||||
      
 | 
			
		||||
      <form onSubmit={handleSubmit}>
 | 
			
		||||
@@ -83,6 +96,7 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
 | 
			
		||||
            placeholder="https://syui.ai/posts/example"
 | 
			
		||||
            required
 | 
			
		||||
            disabled={loading}
 | 
			
		||||
            className="form-input"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
@@ -96,6 +110,7 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
 | 
			
		||||
            rows={4}
 | 
			
		||||
            required
 | 
			
		||||
            disabled={loading}
 | 
			
		||||
            className="form-input form-textarea"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
@@ -109,92 +124,12 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
 | 
			
		||||
          <button 
 | 
			
		||||
            type="submit" 
 | 
			
		||||
            disabled={loading || !text.trim() || !url.trim()}
 | 
			
		||||
            className="submit-btn"
 | 
			
		||||
            className="btn btn-primary"
 | 
			
		||||
          >
 | 
			
		||||
            {loading ? '投稿中...' : 'コメントを投稿'}
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
 | 
			
		||||
      <style jsx>{`
 | 
			
		||||
        .comment-form {
 | 
			
		||||
          border: 2px solid #007bff;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          padding: 20px;
 | 
			
		||||
          margin: 20px 0;
 | 
			
		||||
          background: #f8f9fa;
 | 
			
		||||
        }
 | 
			
		||||
        .comment-form-placeholder {
 | 
			
		||||
          border: 2px dashed #ddd;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          padding: 20px;
 | 
			
		||||
          margin: 20px 0;
 | 
			
		||||
          text-align: center;
 | 
			
		||||
          color: #666;
 | 
			
		||||
          background: #f8f9fa;
 | 
			
		||||
        }
 | 
			
		||||
        .comment-form h3 {
 | 
			
		||||
          margin-top: 0;
 | 
			
		||||
          color: #007bff;
 | 
			
		||||
        }
 | 
			
		||||
        .form-group {
 | 
			
		||||
          margin-bottom: 15px;
 | 
			
		||||
        }
 | 
			
		||||
        .form-group label {
 | 
			
		||||
          display: block;
 | 
			
		||||
          margin-bottom: 5px;
 | 
			
		||||
          font-weight: bold;
 | 
			
		||||
          color: #333;
 | 
			
		||||
        }
 | 
			
		||||
        .form-group input,
 | 
			
		||||
        .form-group textarea {
 | 
			
		||||
          width: 100%;
 | 
			
		||||
          padding: 8px 12px;
 | 
			
		||||
          border: 1px solid #ddd;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          font-size: 14px;
 | 
			
		||||
          box-sizing: border-box;
 | 
			
		||||
        }
 | 
			
		||||
        .form-group input:focus,
 | 
			
		||||
        .form-group textarea:focus {
 | 
			
		||||
          outline: none;
 | 
			
		||||
          border-color: #007bff;
 | 
			
		||||
          box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
 | 
			
		||||
        }
 | 
			
		||||
        .form-group input:disabled,
 | 
			
		||||
        .form-group textarea:disabled {
 | 
			
		||||
          background: #e9ecef;
 | 
			
		||||
          cursor: not-allowed;
 | 
			
		||||
        }
 | 
			
		||||
        .error-message {
 | 
			
		||||
          background: #f8d7da;
 | 
			
		||||
          color: #721c24;
 | 
			
		||||
          padding: 10px;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          margin-bottom: 15px;
 | 
			
		||||
          border: 1px solid #f5c6cb;
 | 
			
		||||
        }
 | 
			
		||||
        .form-actions {
 | 
			
		||||
          margin-top: 20px;
 | 
			
		||||
        }
 | 
			
		||||
        .submit-btn {
 | 
			
		||||
          background: #007bff;
 | 
			
		||||
          color: white;
 | 
			
		||||
          border: none;
 | 
			
		||||
          padding: 10px 20px;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          font-size: 16px;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          transition: background 0.2s;
 | 
			
		||||
        }
 | 
			
		||||
        .submit-btn:hover:not(:disabled) {
 | 
			
		||||
          background: #0056b3;
 | 
			
		||||
        }
 | 
			
		||||
        .submit-btn:disabled {
 | 
			
		||||
          background: #6c757d;
 | 
			
		||||
          cursor: not-allowed;
 | 
			
		||||
        }
 | 
			
		||||
      `}</style>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										98
									
								
								oauth_new/src/components/LoadingSkeleton.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								oauth_new/src/components/LoadingSkeleton.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,98 @@
 | 
			
		||||
import React from 'react'
 | 
			
		||||
 | 
			
		||||
export default function LoadingSkeleton({ count = 3, showTitle = false }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="loading-skeleton">
 | 
			
		||||
      {showTitle && (
 | 
			
		||||
        <div className="skeleton-title">
 | 
			
		||||
          <div className="skeleton-line title"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
      
 | 
			
		||||
      {Array(count).fill(0).map((_, i) => (
 | 
			
		||||
        <div key={i} className="skeleton-item">
 | 
			
		||||
          <div className="skeleton-avatar"></div>
 | 
			
		||||
          <div className="skeleton-content">
 | 
			
		||||
            <div className="skeleton-line name"></div>
 | 
			
		||||
            <div className="skeleton-line text"></div>
 | 
			
		||||
            <div className="skeleton-line text short"></div>
 | 
			
		||||
            <div className="skeleton-line meta"></div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      ))}
 | 
			
		||||
      
 | 
			
		||||
      <style jsx>{`
 | 
			
		||||
        .loading-skeleton {
 | 
			
		||||
          padding: 10px;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .skeleton-title {
 | 
			
		||||
          margin-bottom: 20px;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .skeleton-item {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          padding: 15px;
 | 
			
		||||
          border: 1px solid #eee;
 | 
			
		||||
          margin: 10px 0;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          background: #fafafa;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .skeleton-avatar {
 | 
			
		||||
          width: 32px;
 | 
			
		||||
          height: 32px;
 | 
			
		||||
          border-radius: 50%;
 | 
			
		||||
          background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
 | 
			
		||||
          background-size: 200% 100%;
 | 
			
		||||
          animation: skeleton-loading 1.5s infinite;
 | 
			
		||||
          margin-right: 12px;
 | 
			
		||||
          flex-shrink: 0;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .skeleton-content {
 | 
			
		||||
          flex: 1;
 | 
			
		||||
          min-width: 0;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .skeleton-line {
 | 
			
		||||
          background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
 | 
			
		||||
          background-size: 200% 100%;
 | 
			
		||||
          animation: skeleton-loading 1.5s infinite;
 | 
			
		||||
          margin-bottom: 8px;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .skeleton-line.title {
 | 
			
		||||
          height: 20px;
 | 
			
		||||
          width: 30%;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .skeleton-line.name {
 | 
			
		||||
          height: 14px;
 | 
			
		||||
          width: 25%;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .skeleton-line.text {
 | 
			
		||||
          height: 12px;
 | 
			
		||||
          width: 90%;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .skeleton-line.text.short {
 | 
			
		||||
          width: 60%;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        .skeleton-line.meta {
 | 
			
		||||
          height: 10px;
 | 
			
		||||
          width: 40%;
 | 
			
		||||
          margin-bottom: 0;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        @keyframes skeleton-loading {
 | 
			
		||||
          0% { background-position: 200% 0; }
 | 
			
		||||
          100% { background-position: -200% 0; }
 | 
			
		||||
        }
 | 
			
		||||
      `}</style>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,59 @@
 | 
			
		||||
import React from 'react'
 | 
			
		||||
import React, { useState } from 'react'
 | 
			
		||||
import AvatarImage from './AvatarImage.jsx'
 | 
			
		||||
import Avatar from './Avatar.jsx'
 | 
			
		||||
 | 
			
		||||
export default function RecordList({ title, records, apiConfig, showTitle = true }) {
 | 
			
		||||
export default function RecordList({ title, records, apiConfig, showTitle = true, user = null, agent = null, onRecordDeleted = null }) {
 | 
			
		||||
  const [expandedRecords, setExpandedRecords] = useState(new Set())
 | 
			
		||||
  const [deletingRecords, setDeletingRecords] = useState(new Set())
 | 
			
		||||
 | 
			
		||||
  const toggleJsonView = (index) => {
 | 
			
		||||
    const newExpanded = new Set(expandedRecords)
 | 
			
		||||
    if (newExpanded.has(index)) {
 | 
			
		||||
      newExpanded.delete(index)
 | 
			
		||||
    } else {
 | 
			
		||||
      newExpanded.add(index)
 | 
			
		||||
    }
 | 
			
		||||
    setExpandedRecords(newExpanded)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleDelete = async (record, index) => {
 | 
			
		||||
    if (!user || !agent || !record.uri) return
 | 
			
		||||
    
 | 
			
		||||
    const confirmed = window.confirm('このレコードを削除しますか?')
 | 
			
		||||
    if (!confirmed) return
 | 
			
		||||
 | 
			
		||||
    setDeletingRecords(prev => new Set([...prev, index]))
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      // Extract repo, collection, rkey from URI
 | 
			
		||||
      const uriParts = record.uri.split('/')
 | 
			
		||||
      const repo = uriParts[2]
 | 
			
		||||
      const collection = uriParts[3]
 | 
			
		||||
      const rkey = uriParts[4]
 | 
			
		||||
 | 
			
		||||
      await agent.com.atproto.repo.deleteRecord({
 | 
			
		||||
        repo: repo,
 | 
			
		||||
        collection: collection,
 | 
			
		||||
        rkey: rkey
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      if (onRecordDeleted) {
 | 
			
		||||
        onRecordDeleted()
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      alert(`削除に失敗しました: ${error.message}`)
 | 
			
		||||
    } finally {
 | 
			
		||||
      setDeletingRecords(prev => {
 | 
			
		||||
        const newSet = new Set(prev)
 | 
			
		||||
        newSet.delete(index)
 | 
			
		||||
        return newSet
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const canDelete = (record) => {
 | 
			
		||||
    return user && agent && record.uri && record.value.author?.did === user.did
 | 
			
		||||
  }
 | 
			
		||||
  if (!records || records.length === 0) {
 | 
			
		||||
    return (
 | 
			
		||||
      <section>
 | 
			
		||||
@@ -14,42 +67,68 @@ export default function RecordList({ title, records, apiConfig, showTitle = true
 | 
			
		||||
    <section>
 | 
			
		||||
      {showTitle && <h3>{title} ({records.length})</h3>}
 | 
			
		||||
      {records.map((record, i) => (
 | 
			
		||||
        <div key={i} style={{ border: '1px solid #ddd', margin: '10px 0', padding: '10px' }}>
 | 
			
		||||
          {record.value.author?.avatar && (
 | 
			
		||||
            <img 
 | 
			
		||||
              src={record.value.author.avatar} 
 | 
			
		||||
              alt="avatar" 
 | 
			
		||||
              style={{ width: '32px', height: '32px', borderRadius: '50%', marginRight: '10px' }} 
 | 
			
		||||
            />
 | 
			
		||||
          )}
 | 
			
		||||
          <div><strong>{record.value.author?.displayName || record.value.author?.handle}</strong></div>
 | 
			
		||||
          <div>
 | 
			
		||||
            Handle: 
 | 
			
		||||
            <a 
 | 
			
		||||
              href={`${apiConfig?.web}/profile/${record.value.author?.did}`} 
 | 
			
		||||
              target="_blank" 
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
              style={{ marginLeft: '5px' }}
 | 
			
		||||
            >
 | 
			
		||||
              {record.value.author?.handle}
 | 
			
		||||
            </a>
 | 
			
		||||
        <div key={i} className="record-item">
 | 
			
		||||
          <div className="record-header">
 | 
			
		||||
            <AvatarImage record={record} size={40} />
 | 
			
		||||
            <div className="user-info">
 | 
			
		||||
              <div className="display-name">{record.value.author?.displayName || record.value.author?.handle}</div>
 | 
			
		||||
              <div className="handle">
 | 
			
		||||
                <a 
 | 
			
		||||
                  href={`${apiConfig?.web || 'https://bsky.app'}/profile/${record.value.author?.did}`}
 | 
			
		||||
                  target="_blank"
 | 
			
		||||
                  rel="noopener noreferrer"
 | 
			
		||||
                  className="handle-link"
 | 
			
		||||
                >
 | 
			
		||||
                  @{record.value.author?.handle}
 | 
			
		||||
                </a>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="timestamp">{new Date(record.value.createdAt).toLocaleString()}</div>
 | 
			
		||||
            </div>
 | 
			
		||||
            
 | 
			
		||||
            <div className="record-actions">
 | 
			
		||||
              <button
 | 
			
		||||
                onClick={() => toggleJsonView(i)}
 | 
			
		||||
                className={`btn btn-sm ${expandedRecords.has(i) ? 'btn-outline' : 'btn-primary'}`}
 | 
			
		||||
                title="Show/Hide JSON"
 | 
			
		||||
              >
 | 
			
		||||
                {expandedRecords.has(i) ? 'hide' : 'json'}
 | 
			
		||||
              </button>
 | 
			
		||||
              
 | 
			
		||||
              {canDelete(record) && (
 | 
			
		||||
                <button
 | 
			
		||||
                  onClick={() => handleDelete(record, i)}
 | 
			
		||||
                  disabled={deletingRecords.has(i)}
 | 
			
		||||
                  className="btn btn-danger btn-sm"
 | 
			
		||||
                  title="Delete Record"
 | 
			
		||||
                >
 | 
			
		||||
                  {deletingRecords.has(i) ? 'deleting...' : 'delete'}
 | 
			
		||||
                </button>
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div style={{ margin: '10px 0' }}>{record.value.text || record.value.content}</div>
 | 
			
		||||
          {record.value.post?.url && (
 | 
			
		||||
            <div>
 | 
			
		||||
              URL: 
 | 
			
		||||
          
 | 
			
		||||
          {expandedRecords.has(i) && (
 | 
			
		||||
            <div className="json-display">
 | 
			
		||||
              <div className="json-header">json data</div>
 | 
			
		||||
              <pre className="json-content">
 | 
			
		||||
                {JSON.stringify(record, null, 2)}
 | 
			
		||||
              </pre>
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
          
 | 
			
		||||
          <div className="record-content">{record.value.text || record.value.content}</div>
 | 
			
		||||
          
 | 
			
		||||
          <div className="record-meta">
 | 
			
		||||
            {record.value.post?.url && (
 | 
			
		||||
              <a 
 | 
			
		||||
                href={record.value.post.url} 
 | 
			
		||||
                target="_blank" 
 | 
			
		||||
                rel="noopener noreferrer"
 | 
			
		||||
                style={{ marginLeft: '5px' }}
 | 
			
		||||
                className="record-url"
 | 
			
		||||
              >
 | 
			
		||||
                {record.value.post.url}
 | 
			
		||||
              </a>
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
          <div style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
 | 
			
		||||
            {new Date(record.value.createdAt).toLocaleString()}
 | 
			
		||||
            )}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      ))}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,8 @@
 | 
			
		||||
import React, { useState } from 'react'
 | 
			
		||||
import RecordList from './RecordList.jsx'
 | 
			
		||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
 | 
			
		||||
 | 
			
		||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
 | 
			
		||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
 | 
			
		||||
  const [activeTab, setActiveTab] = useState('lang')
 | 
			
		||||
 | 
			
		||||
  // Filter records based on page context
 | 
			
		||||
@@ -12,7 +13,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
 | 
			
		||||
    } else {
 | 
			
		||||
      // Individual page: show records matching the URL
 | 
			
		||||
      return records.filter(record => {
 | 
			
		||||
        const recordUrl = record.value.post?.url
 | 
			
		||||
        const recordUrl = record.value?.post?.url
 | 
			
		||||
        if (!recordUrl) return false
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
@@ -29,6 +30,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
 | 
			
		||||
  const filteredCommentRecords = filterRecords(commentRecords)
 | 
			
		||||
  const filteredUserComments = filterRecords(userComments || [])
 | 
			
		||||
  const filteredChatRecords = filterRecords(chatRecords || [])
 | 
			
		||||
  const filteredBaseRecords = filterRecords(baseRecords || [])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="record-tabs">
 | 
			
		||||
@@ -37,115 +39,91 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
 | 
			
		||||
          className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
 | 
			
		||||
          onClick={() => setActiveTab('lang')}
 | 
			
		||||
        >
 | 
			
		||||
          Lang Records ({filteredLangRecords.length})
 | 
			
		||||
          Lang ({filteredLangRecords.length})
 | 
			
		||||
        </button>
 | 
			
		||||
        <button 
 | 
			
		||||
          className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
 | 
			
		||||
          onClick={() => setActiveTab('comment')}
 | 
			
		||||
        >
 | 
			
		||||
          Comment Records ({filteredCommentRecords.length})
 | 
			
		||||
          Comment ({filteredCommentRecords.length})
 | 
			
		||||
        </button>
 | 
			
		||||
        <button 
 | 
			
		||||
          className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
 | 
			
		||||
          onClick={() => setActiveTab('collection')}
 | 
			
		||||
        >
 | 
			
		||||
          Collection ({filteredChatRecords.length})
 | 
			
		||||
          Posts ({filteredBaseRecords.length})
 | 
			
		||||
        </button>
 | 
			
		||||
        <button 
 | 
			
		||||
          className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
 | 
			
		||||
          onClick={() => setActiveTab('users')}
 | 
			
		||||
        >
 | 
			
		||||
          User Comments ({filteredUserComments.length})
 | 
			
		||||
          Users ({filteredUserComments.length})
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="tab-content">
 | 
			
		||||
        {activeTab === 'lang' && (
 | 
			
		||||
          <RecordList 
 | 
			
		||||
            title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
 | 
			
		||||
            records={filteredLangRecords} 
 | 
			
		||||
            apiConfig={apiConfig} 
 | 
			
		||||
          />
 | 
			
		||||
          !langRecords ? (
 | 
			
		||||
            <LoadingSkeleton count={3} showTitle={true} />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <RecordList 
 | 
			
		||||
              title=""
 | 
			
		||||
              records={filteredLangRecords} 
 | 
			
		||||
              apiConfig={apiConfig} 
 | 
			
		||||
              user={user}
 | 
			
		||||
              agent={agent}
 | 
			
		||||
              onRecordDeleted={onRecordDeleted}
 | 
			
		||||
              showTitle={false}
 | 
			
		||||
            />
 | 
			
		||||
          )
 | 
			
		||||
        )}
 | 
			
		||||
        {activeTab === 'comment' && (
 | 
			
		||||
          <RecordList 
 | 
			
		||||
            title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
 | 
			
		||||
            records={filteredCommentRecords} 
 | 
			
		||||
            apiConfig={apiConfig} 
 | 
			
		||||
          />
 | 
			
		||||
          !commentRecords ? (
 | 
			
		||||
            <LoadingSkeleton count={3} showTitle={true} />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <RecordList 
 | 
			
		||||
              title=""
 | 
			
		||||
              records={filteredCommentRecords} 
 | 
			
		||||
              apiConfig={apiConfig} 
 | 
			
		||||
              user={user}
 | 
			
		||||
              agent={agent}
 | 
			
		||||
              onRecordDeleted={onRecordDeleted}
 | 
			
		||||
              showTitle={false}
 | 
			
		||||
            />
 | 
			
		||||
          )
 | 
			
		||||
        )}
 | 
			
		||||
        {activeTab === 'collection' && (
 | 
			
		||||
          <RecordList 
 | 
			
		||||
            title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
 | 
			
		||||
            records={filteredChatRecords} 
 | 
			
		||||
            apiConfig={apiConfig} 
 | 
			
		||||
          />
 | 
			
		||||
          !baseRecords ? (
 | 
			
		||||
            <LoadingSkeleton count={2} showTitle={true} />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <RecordList 
 | 
			
		||||
              title=""
 | 
			
		||||
              records={filteredBaseRecords} 
 | 
			
		||||
              apiConfig={apiConfig} 
 | 
			
		||||
              user={user}
 | 
			
		||||
              agent={agent}
 | 
			
		||||
              onRecordDeleted={onRecordDeleted}
 | 
			
		||||
              showTitle={false}
 | 
			
		||||
            />
 | 
			
		||||
          )
 | 
			
		||||
        )}
 | 
			
		||||
        {activeTab === 'users' && (
 | 
			
		||||
          <RecordList 
 | 
			
		||||
            title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
 | 
			
		||||
            records={filteredUserComments} 
 | 
			
		||||
            apiConfig={apiConfig} 
 | 
			
		||||
          />
 | 
			
		||||
          !userComments ? (
 | 
			
		||||
            <LoadingSkeleton count={3} showTitle={true} />
 | 
			
		||||
          ) : (
 | 
			
		||||
            <RecordList 
 | 
			
		||||
              title=""
 | 
			
		||||
              records={filteredUserComments} 
 | 
			
		||||
              apiConfig={apiConfig} 
 | 
			
		||||
              user={user}
 | 
			
		||||
              agent={agent}
 | 
			
		||||
              onRecordDeleted={onRecordDeleted}
 | 
			
		||||
              showTitle={false}
 | 
			
		||||
            />
 | 
			
		||||
          )
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="page-info">
 | 
			
		||||
        <small>
 | 
			
		||||
          {pageContext.isTopPage 
 | 
			
		||||
            ? "トップページ: 最新3件を表示" 
 | 
			
		||||
            : `個別ページ: ${pageContext.rkey} に関連するレコードを表示`
 | 
			
		||||
          }
 | 
			
		||||
        </small>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <style jsx>{`
 | 
			
		||||
        .record-tabs {
 | 
			
		||||
          margin: 20px 0;
 | 
			
		||||
        }
 | 
			
		||||
        .tab-header {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          border-bottom: 2px solid #ddd;
 | 
			
		||||
          margin-bottom: 10px;
 | 
			
		||||
        }
 | 
			
		||||
        .tab-btn {
 | 
			
		||||
          padding: 10px 20px;
 | 
			
		||||
          border: none;
 | 
			
		||||
          background: #f8f9fa;
 | 
			
		||||
          border-top: 2px solid transparent;
 | 
			
		||||
          border-left: 1px solid #ddd;
 | 
			
		||||
          border-right: 1px solid #ddd;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          font-size: 14px;
 | 
			
		||||
          transition: all 0.2s;
 | 
			
		||||
        }
 | 
			
		||||
        .tab-btn:first-child {
 | 
			
		||||
          border-left: none;
 | 
			
		||||
        }
 | 
			
		||||
        .tab-btn:last-child {
 | 
			
		||||
          border-right: none;
 | 
			
		||||
        }
 | 
			
		||||
        .tab-btn.active {
 | 
			
		||||
          background: white;
 | 
			
		||||
          border-top-color: #007bff;
 | 
			
		||||
          border-bottom: 2px solid white;
 | 
			
		||||
          margin-bottom: -2px;
 | 
			
		||||
          font-weight: bold;
 | 
			
		||||
        }
 | 
			
		||||
        .tab-btn:hover:not(.active) {
 | 
			
		||||
          background: #e9ecef;
 | 
			
		||||
        }
 | 
			
		||||
        .tab-content {
 | 
			
		||||
          min-height: 200px;
 | 
			
		||||
        }
 | 
			
		||||
        .page-info {
 | 
			
		||||
          margin-top: 10px;
 | 
			
		||||
          padding: 5px 10px;
 | 
			
		||||
          background: #f8f9fa;
 | 
			
		||||
          border-radius: 3px;
 | 
			
		||||
          color: #666;
 | 
			
		||||
        }
 | 
			
		||||
      `}</style>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										531
									
								
								oauth_new/src/components/TestUI.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										531
									
								
								oauth_new/src/components/TestUI.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,531 @@
 | 
			
		||||
import React, { useState } from 'react'
 | 
			
		||||
import { env } from '../config/env.js'
 | 
			
		||||
import AvatarTestPanel from './AvatarTestPanel.jsx'
 | 
			
		||||
import AvatarTest from './AvatarTest.jsx'
 | 
			
		||||
 | 
			
		||||
export default function TestUI() {
 | 
			
		||||
  const [activeTab, setActiveTab] = useState('putRecord')
 | 
			
		||||
  const [accessJwt, setAccessJwt] = useState('')
 | 
			
		||||
  const [handle, setHandle] = useState('')
 | 
			
		||||
  const [sessionDid, setSessionDid] = useState('')
 | 
			
		||||
  const [collection, setCollection] = useState('ai.syui.log')
 | 
			
		||||
  const [loading, setLoading] = useState(false)
 | 
			
		||||
  const [error, setError] = useState(null)
 | 
			
		||||
  const [success, setSuccess] = useState(null)
 | 
			
		||||
  const [showJson, setShowJson] = useState(false)
 | 
			
		||||
  const [lastRecord, setLastRecord] = useState(null)
 | 
			
		||||
 | 
			
		||||
  const collections = [
 | 
			
		||||
    'ai.syui.log',
 | 
			
		||||
    'ai.syui.log.chat',
 | 
			
		||||
    'ai.syui.log.chat.lang',
 | 
			
		||||
    'ai.syui.log.chat.comment'
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  const generateDummyData = (collectionType) => {
 | 
			
		||||
    const timestamp = new Date().toISOString()
 | 
			
		||||
    const url = 'https://syui.ai/test/dummy'
 | 
			
		||||
    
 | 
			
		||||
    const basePost = {
 | 
			
		||||
      url: url,
 | 
			
		||||
      date: timestamp,
 | 
			
		||||
      slug: 'dummy-test',
 | 
			
		||||
      tags: ['test', 'dummy'],
 | 
			
		||||
      title: 'Test Post',
 | 
			
		||||
      language: 'ja'
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const baseAuthor = {
 | 
			
		||||
      did: sessionDid || null, // Use real session DID if available, otherwise null
 | 
			
		||||
      handle: handle || 'test.user',
 | 
			
		||||
      displayName: 'Test User',
 | 
			
		||||
      avatar: null
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    switch (collectionType) {
 | 
			
		||||
      case 'ai.syui.log':
 | 
			
		||||
        return {
 | 
			
		||||
          $type: collectionType,
 | 
			
		||||
          url: url,
 | 
			
		||||
          post: basePost,
 | 
			
		||||
          text: 'テストコメントです。これはダミーデータです。',
 | 
			
		||||
          type: 'comment',
 | 
			
		||||
          author: baseAuthor,
 | 
			
		||||
          createdAt: timestamp
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
      case 'ai.syui.log.chat':
 | 
			
		||||
        const isQuestion = Math.random() > 0.5
 | 
			
		||||
        return {
 | 
			
		||||
          $type: collectionType,
 | 
			
		||||
          post: basePost,
 | 
			
		||||
          text: isQuestion ? 'これはテスト用の質問です。' : 'これはテスト用のAI回答です。詳しく説明します。',
 | 
			
		||||
          type: isQuestion ? 'question' : 'answer',
 | 
			
		||||
          author: isQuestion ? baseAuthor : {
 | 
			
		||||
            did: 'did:plc:ai-test',
 | 
			
		||||
            handle: 'ai.syui.ai',
 | 
			
		||||
            displayName: 'ai',
 | 
			
		||||
            avatar: null
 | 
			
		||||
          },
 | 
			
		||||
          createdAt: timestamp
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
      case 'ai.syui.log.chat.lang':
 | 
			
		||||
        return {
 | 
			
		||||
          $type: collectionType,
 | 
			
		||||
          post: basePost,
 | 
			
		||||
          text: 'This is a test translation. Hello, this is a dummy English translation of the Japanese post.',
 | 
			
		||||
          type: 'en',
 | 
			
		||||
          author: {
 | 
			
		||||
            did: 'did:plc:ai-test',
 | 
			
		||||
            handle: 'ai.syui.ai',
 | 
			
		||||
            displayName: 'ai',
 | 
			
		||||
            avatar: null
 | 
			
		||||
          },
 | 
			
		||||
          createdAt: timestamp
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
      case 'ai.syui.log.chat.comment':
 | 
			
		||||
        return {
 | 
			
		||||
          $type: collectionType,
 | 
			
		||||
          post: basePost,
 | 
			
		||||
          text: 'これはAIによるテストコメントです。記事についての感想や補足情報を提供します。',
 | 
			
		||||
          author: {
 | 
			
		||||
            did: 'did:plc:ai-test',
 | 
			
		||||
            handle: 'ai.syui.ai',
 | 
			
		||||
            displayName: 'ai',
 | 
			
		||||
            avatar: null
 | 
			
		||||
          },
 | 
			
		||||
          createdAt: timestamp
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
      default:
 | 
			
		||||
        return {}
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleSubmit = async (e) => {
 | 
			
		||||
    e.preventDefault()
 | 
			
		||||
    if (!accessJwt.trim() || !handle.trim()) {
 | 
			
		||||
      setError('Access JWT and Handle are required')
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setLoading(true)
 | 
			
		||||
    setError(null)
 | 
			
		||||
    setSuccess(null)
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const recordData = generateDummyData(collection)
 | 
			
		||||
      const rkey = `test-${Date.now()}`
 | 
			
		||||
      
 | 
			
		||||
      const record = {
 | 
			
		||||
        repo: handle, // Use handle as is, without adding .bsky.social
 | 
			
		||||
        collection: collection,
 | 
			
		||||
        rkey: rkey,
 | 
			
		||||
        record: recordData
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      setLastRecord(record)
 | 
			
		||||
 | 
			
		||||
      // Direct API call with accessJwt
 | 
			
		||||
      const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.putRecord`, {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
          'Authorization': `Bearer ${accessJwt}`
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify(record)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        const errorData = await response.json()
 | 
			
		||||
        throw new Error(`API Error: ${response.status} - ${errorData.message || response.statusText}`)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const result = await response.json()
 | 
			
		||||
      setSuccess(`Record created successfully! URI: ${result.uri}`)
 | 
			
		||||
      
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      setError(err.message)
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const handleDelete = async () => {
 | 
			
		||||
    if (!lastRecord || !accessJwt.trim()) {
 | 
			
		||||
      setError('No record to delete or missing access JWT')
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setLoading(true)
 | 
			
		||||
    setError(null)
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const deleteData = {
 | 
			
		||||
        repo: lastRecord.repo,
 | 
			
		||||
        collection: lastRecord.collection,
 | 
			
		||||
        rkey: lastRecord.rkey
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.deleteRecord`, {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
          'Authorization': `Bearer ${accessJwt}`
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify(deleteData)
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        const errorData = await response.json()
 | 
			
		||||
        throw new Error(`Delete Error: ${response.status} - ${errorData.message || response.statusText}`)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      setSuccess('Record deleted successfully!')
 | 
			
		||||
      setLastRecord(null)
 | 
			
		||||
      
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      setError(err.message)
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="test-ui">
 | 
			
		||||
      <h2>🧪 Test UI</h2>
 | 
			
		||||
      
 | 
			
		||||
      {/* Tab Navigation */}
 | 
			
		||||
      <div className="test-tabs">
 | 
			
		||||
        <button 
 | 
			
		||||
          onClick={() => setActiveTab('putRecord')}
 | 
			
		||||
          className={`test-tab ${activeTab === 'putRecord' ? 'active' : ''}`}
 | 
			
		||||
        >
 | 
			
		||||
          Manual putRecord
 | 
			
		||||
        </button>
 | 
			
		||||
        <button 
 | 
			
		||||
          onClick={() => setActiveTab('avatar')}
 | 
			
		||||
          className={`test-tab ${activeTab === 'avatar' ? 'active' : ''}`}
 | 
			
		||||
        >
 | 
			
		||||
          Avatar System
 | 
			
		||||
        </button>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      {activeTab === 'putRecord' && (
 | 
			
		||||
        <div className="test-content">
 | 
			
		||||
          <p className="description">
 | 
			
		||||
            OAuth不要のテスト用UI。accessJwtとhandleを直接入力して各collectionにダミーデータを投稿できます。
 | 
			
		||||
          </p>
 | 
			
		||||
 | 
			
		||||
      <form onSubmit={handleSubmit}>
 | 
			
		||||
        <div className="form-group">
 | 
			
		||||
          <label htmlFor="access-jwt">Access JWT:</label>
 | 
			
		||||
          <textarea
 | 
			
		||||
            id="access-jwt"
 | 
			
		||||
            value={accessJwt}
 | 
			
		||||
            onChange={(e) => setAccessJwt(e.target.value)}
 | 
			
		||||
            placeholder="eyJ... (Access JWT token)"
 | 
			
		||||
            rows={3}
 | 
			
		||||
            required
 | 
			
		||||
            disabled={loading}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className="form-group">
 | 
			
		||||
          <label htmlFor="handle">Handle:</label>
 | 
			
		||||
          <input
 | 
			
		||||
            id="handle"
 | 
			
		||||
            type="text"
 | 
			
		||||
            value={handle}
 | 
			
		||||
            onChange={(e) => setHandle(e.target.value)}
 | 
			
		||||
            placeholder="user.bsky.social"
 | 
			
		||||
            required
 | 
			
		||||
            disabled={loading}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className="form-group">
 | 
			
		||||
          <label htmlFor="session-did">Session DID (optional):</label>
 | 
			
		||||
          <input
 | 
			
		||||
            id="session-did"
 | 
			
		||||
            type="text"
 | 
			
		||||
            value={sessionDid}
 | 
			
		||||
            onChange={(e) => setSessionDid(e.target.value)}
 | 
			
		||||
            placeholder="did:plc:xxxxx (Leave empty to use test DID)"
 | 
			
		||||
            disabled={loading}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div className="form-group">
 | 
			
		||||
          <label htmlFor="collection">Collection:</label>
 | 
			
		||||
          <select
 | 
			
		||||
            id="collection"
 | 
			
		||||
            value={collection}
 | 
			
		||||
            onChange={(e) => setCollection(e.target.value)}
 | 
			
		||||
            disabled={loading}
 | 
			
		||||
          >
 | 
			
		||||
            {collections.map(col => (
 | 
			
		||||
              <option key={col} value={col}>{col}</option>
 | 
			
		||||
            ))}
 | 
			
		||||
          </select>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {error && (
 | 
			
		||||
          <div className="error-message">
 | 
			
		||||
            ❌ {error}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        {success && (
 | 
			
		||||
          <div className="success-message">
 | 
			
		||||
            ✅ {success}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <div className="form-actions">
 | 
			
		||||
          <button 
 | 
			
		||||
            type="submit" 
 | 
			
		||||
            disabled={loading || !accessJwt.trim() || !handle.trim()}
 | 
			
		||||
            className="submit-btn"
 | 
			
		||||
          >
 | 
			
		||||
            {loading ? '⏳ Creating...' : '📤 Create Record'}
 | 
			
		||||
          </button>
 | 
			
		||||
 | 
			
		||||
          <button
 | 
			
		||||
            type="button"
 | 
			
		||||
            onClick={() => setShowJson(!showJson)}
 | 
			
		||||
            className="json-btn"
 | 
			
		||||
            disabled={loading}
 | 
			
		||||
          >
 | 
			
		||||
            {showJson ? '🙈 Hide JSON' : '👁️ Show JSON'}
 | 
			
		||||
          </button>
 | 
			
		||||
 | 
			
		||||
          {lastRecord && (
 | 
			
		||||
            <button
 | 
			
		||||
              type="button"
 | 
			
		||||
              onClick={handleDelete}
 | 
			
		||||
              className="delete-btn"
 | 
			
		||||
              disabled={loading}
 | 
			
		||||
            >
 | 
			
		||||
              {loading ? '⏳ Deleting...' : '🗑️ Delete Last Record'}
 | 
			
		||||
            </button>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </form>
 | 
			
		||||
 | 
			
		||||
      {showJson && (
 | 
			
		||||
        <div className="json-preview">
 | 
			
		||||
          <h3>Generated JSON:</h3>
 | 
			
		||||
          <pre>{JSON.stringify(generateDummyData(collection), null, 2)}</pre>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {lastRecord && (
 | 
			
		||||
        <div className="last-record">
 | 
			
		||||
          <h3>Last Created Record:</h3>
 | 
			
		||||
          <div className="record-info">
 | 
			
		||||
            <p><strong>Collection:</strong> {lastRecord.collection}</p>
 | 
			
		||||
            <p><strong>RKey:</strong> {lastRecord.rkey}</p>
 | 
			
		||||
            <p><strong>Repo:</strong> {lastRecord.repo}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {activeTab === 'avatar' && (
 | 
			
		||||
        <div className="test-content">
 | 
			
		||||
          <AvatarTestPanel />
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <style jsx>{`
 | 
			
		||||
        .test-ui {
 | 
			
		||||
          border: 3px solid #ff6b6b;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          padding: 20px;
 | 
			
		||||
          margin: 20px 0;
 | 
			
		||||
          background: #fff5f5;
 | 
			
		||||
        }
 | 
			
		||||
        .test-tabs {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          gap: 10px;
 | 
			
		||||
          margin-bottom: 20px;
 | 
			
		||||
          border-bottom: 2px solid #ddd;
 | 
			
		||||
          padding-bottom: 10px;
 | 
			
		||||
        }
 | 
			
		||||
        .test-tab {
 | 
			
		||||
          background: #f8f9fa;
 | 
			
		||||
          border: 1px solid #ddd;
 | 
			
		||||
          padding: 8px 16px;
 | 
			
		||||
          border-radius: 4px 4px 0 0;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          font-size: 14px;
 | 
			
		||||
          font-weight: 600;
 | 
			
		||||
          color: #666;
 | 
			
		||||
          transition: all 0.2s;
 | 
			
		||||
        }
 | 
			
		||||
        .test-tab:hover {
 | 
			
		||||
          background: #e9ecef;
 | 
			
		||||
          color: #333;
 | 
			
		||||
        }
 | 
			
		||||
        .test-tab.active {
 | 
			
		||||
          background: #ff6b6b;
 | 
			
		||||
          color: white;
 | 
			
		||||
          border-color: #ff6b6b;
 | 
			
		||||
        }
 | 
			
		||||
        .test-content {
 | 
			
		||||
          margin-top: 20px;
 | 
			
		||||
        }
 | 
			
		||||
        .test-ui h2 {
 | 
			
		||||
          color: #ff6b6b;
 | 
			
		||||
          margin-top: 0;
 | 
			
		||||
        }
 | 
			
		||||
        .description {
 | 
			
		||||
          color: #666;
 | 
			
		||||
          font-style: italic;
 | 
			
		||||
          margin-bottom: 20px;
 | 
			
		||||
        }
 | 
			
		||||
        .form-group {
 | 
			
		||||
          margin-bottom: 15px;
 | 
			
		||||
        }
 | 
			
		||||
        .form-group label {
 | 
			
		||||
          display: block;
 | 
			
		||||
          margin-bottom: 5px;
 | 
			
		||||
          font-weight: bold;
 | 
			
		||||
          color: #333;
 | 
			
		||||
        }
 | 
			
		||||
        .form-group input,
 | 
			
		||||
        .form-group textarea,
 | 
			
		||||
        .form-group select {
 | 
			
		||||
          width: 100%;
 | 
			
		||||
          padding: 8px 12px;
 | 
			
		||||
          border: 1px solid #ddd;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          font-size: 14px;
 | 
			
		||||
          box-sizing: border-box;
 | 
			
		||||
          font-family: monospace;
 | 
			
		||||
        }
 | 
			
		||||
        .form-group textarea {
 | 
			
		||||
          resize: vertical;
 | 
			
		||||
          min-height: 80px;
 | 
			
		||||
        }
 | 
			
		||||
        .form-group input:focus,
 | 
			
		||||
        .form-group textarea:focus,
 | 
			
		||||
        .form-group select:focus {
 | 
			
		||||
          outline: none;
 | 
			
		||||
          border-color: #ff6b6b;
 | 
			
		||||
          box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.25);
 | 
			
		||||
        }
 | 
			
		||||
        .form-group input:disabled,
 | 
			
		||||
        .form-group textarea:disabled,
 | 
			
		||||
        .form-group select:disabled {
 | 
			
		||||
          background: #f8f9fa;
 | 
			
		||||
          cursor: not-allowed;
 | 
			
		||||
        }
 | 
			
		||||
        .error-message {
 | 
			
		||||
          background: #f8d7da;
 | 
			
		||||
          color: #721c24;
 | 
			
		||||
          padding: 10px;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          margin-bottom: 15px;
 | 
			
		||||
          border: 1px solid #f5c6cb;
 | 
			
		||||
        }
 | 
			
		||||
        .success-message {
 | 
			
		||||
          background: #d4edda;
 | 
			
		||||
          color: #155724;
 | 
			
		||||
          padding: 10px;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          margin-bottom: 15px;
 | 
			
		||||
          border: 1px solid #c3e6cb;
 | 
			
		||||
        }
 | 
			
		||||
        .form-actions {
 | 
			
		||||
          display: flex;
 | 
			
		||||
          gap: 10px;
 | 
			
		||||
          flex-wrap: wrap;
 | 
			
		||||
          margin-top: 20px;
 | 
			
		||||
        }
 | 
			
		||||
        .submit-btn {
 | 
			
		||||
          background: #ff6b6b;
 | 
			
		||||
          color: white;
 | 
			
		||||
          border: none;
 | 
			
		||||
          padding: 10px 20px;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          font-size: 16px;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          transition: background 0.2s;
 | 
			
		||||
        }
 | 
			
		||||
        .submit-btn:hover:not(:disabled) {
 | 
			
		||||
          background: #ff5252;
 | 
			
		||||
        }
 | 
			
		||||
        .submit-btn:disabled {
 | 
			
		||||
          background: #6c757d;
 | 
			
		||||
          cursor: not-allowed;
 | 
			
		||||
        }
 | 
			
		||||
        .json-btn {
 | 
			
		||||
          background: #17a2b8;
 | 
			
		||||
          color: white;
 | 
			
		||||
          border: none;
 | 
			
		||||
          padding: 10px 20px;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          font-size: 16px;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          transition: background 0.2s;
 | 
			
		||||
        }
 | 
			
		||||
        .json-btn:hover:not(:disabled) {
 | 
			
		||||
          background: #138496;
 | 
			
		||||
        }
 | 
			
		||||
        .delete-btn {
 | 
			
		||||
          background: #dc3545;
 | 
			
		||||
          color: white;
 | 
			
		||||
          border: none;
 | 
			
		||||
          padding: 10px 20px;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          font-size: 16px;
 | 
			
		||||
          cursor: pointer;
 | 
			
		||||
          transition: background 0.2s;
 | 
			
		||||
        }
 | 
			
		||||
        .delete-btn:hover:not(:disabled) {
 | 
			
		||||
          background: #c82333;
 | 
			
		||||
        }
 | 
			
		||||
        .json-preview {
 | 
			
		||||
          margin-top: 20px;
 | 
			
		||||
          padding: 15px;
 | 
			
		||||
          background: #f8f9fa;
 | 
			
		||||
          border: 1px solid #dee2e6;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
        }
 | 
			
		||||
        .json-preview h3 {
 | 
			
		||||
          margin-top: 0;
 | 
			
		||||
          color: #495057;
 | 
			
		||||
        }
 | 
			
		||||
        .json-preview pre {
 | 
			
		||||
          background: #e9ecef;
 | 
			
		||||
          padding: 10px;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
          overflow-x: auto;
 | 
			
		||||
          font-size: 12px;
 | 
			
		||||
          margin: 0;
 | 
			
		||||
        }
 | 
			
		||||
        .last-record {
 | 
			
		||||
          margin-top: 20px;
 | 
			
		||||
          padding: 15px;
 | 
			
		||||
          background: #e7f3ff;
 | 
			
		||||
          border: 1px solid #b3d9ff;
 | 
			
		||||
          border-radius: 4px;
 | 
			
		||||
        }
 | 
			
		||||
        .last-record h3 {
 | 
			
		||||
          margin-top: 0;
 | 
			
		||||
          color: #0066cc;
 | 
			
		||||
        }
 | 
			
		||||
        .record-info p {
 | 
			
		||||
          margin: 5px 0;
 | 
			
		||||
          font-family: monospace;
 | 
			
		||||
          font-size: 14px;
 | 
			
		||||
        }
 | 
			
		||||
      `}</style>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
 | 
			
		||||
import { atproto, collections } from '../api/atproto.js'
 | 
			
		||||
import { getApiConfig } from '../utils/pds.js'
 | 
			
		||||
import { env } from '../config/env.js'
 | 
			
		||||
import { getErrorMessage, logError } from '../utils/errorHandler.js'
 | 
			
		||||
 | 
			
		||||
export function useAdminData() {
 | 
			
		||||
  const [adminData, setAdminData] = useState({ 
 | 
			
		||||
@@ -14,6 +15,7 @@ export function useAdminData() {
 | 
			
		||||
  const [commentRecords, setCommentRecords] = useState([])
 | 
			
		||||
  const [loading, setLoading] = useState(true)
 | 
			
		||||
  const [error, setError] = useState(null)
 | 
			
		||||
  const [retryCount, setRetryCount] = useState(0)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    loadAdminData()
 | 
			
		||||
@@ -38,9 +40,18 @@ export function useAdminData() {
 | 
			
		||||
      setAdminData({ did, profile, records, apiConfig })
 | 
			
		||||
      setLangRecords(lang)
 | 
			
		||||
      setCommentRecords(comment)
 | 
			
		||||
      setRetryCount(0) // 成功時はリトライカウントをリセット
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error('Failed to load admin data:', err)
 | 
			
		||||
      setError(err.message)
 | 
			
		||||
      logError(err, 'useAdminData.loadAdminData')
 | 
			
		||||
      setError(getErrorMessage(err))
 | 
			
		||||
      
 | 
			
		||||
      // 自動リトライ(最大3回)
 | 
			
		||||
      if (retryCount < 3) {
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          setRetryCount(prev => prev + 1)
 | 
			
		||||
          loadAdminData()
 | 
			
		||||
        }, Math.pow(2, retryCount) * 1000) // 1s, 2s, 4s
 | 
			
		||||
      }
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
    }
 | 
			
		||||
@@ -52,6 +63,7 @@ export function useAdminData() {
 | 
			
		||||
    commentRecords,
 | 
			
		||||
    loading,
 | 
			
		||||
    error,
 | 
			
		||||
    retryCount,
 | 
			
		||||
    refresh: loadAdminData
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										234
									
								
								oauth_new/src/hooks/useAskAI.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								oauth_new/src/hooks/useAskAI.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,234 @@
 | 
			
		||||
import { useState } from 'react'
 | 
			
		||||
import { atproto, collections } from '../api/atproto.js'
 | 
			
		||||
import { env } from '../config/env.js'
 | 
			
		||||
import { logger } from '../utils/logger.js'
 | 
			
		||||
import { getErrorMessage, logError } from '../utils/errorHandler.js'
 | 
			
		||||
 | 
			
		||||
export function useAskAI(adminData, userProfile, agent) {
 | 
			
		||||
  const [loading, setLoading] = useState(false)
 | 
			
		||||
  const [error, setError] = useState(null)
 | 
			
		||||
  const [chatHistory, setChatHistory] = useState([])
 | 
			
		||||
 | 
			
		||||
  // ask-AIサーバーのURL(環境変数から取得、フォールバック付き)
 | 
			
		||||
  const askAIUrl = import.meta.env.VITE_ASK_AI_URL || 'http://localhost:3000/ask'
 | 
			
		||||
 | 
			
		||||
  const askQuestion = async (question) => {
 | 
			
		||||
    if (!question.trim()) return
 | 
			
		||||
 | 
			
		||||
    setLoading(true)
 | 
			
		||||
    setError(null)
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      logger.log('Sending question to ask-AI:', question)
 | 
			
		||||
 | 
			
		||||
      // ask-AIサーバーにリクエスト送信
 | 
			
		||||
      const response = await fetch(askAIUrl, {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
        },
 | 
			
		||||
        body: JSON.stringify({
 | 
			
		||||
          question: question.trim(),
 | 
			
		||||
          context: {
 | 
			
		||||
            url: window.location.href,
 | 
			
		||||
            timestamp: new Date().toISOString()
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`ask-AI server error: ${response.status}`)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const aiResponse = await response.json()
 | 
			
		||||
      logger.log('Received AI response:', aiResponse)
 | 
			
		||||
 | 
			
		||||
      // AI回答をチャット履歴に追加
 | 
			
		||||
      const chatEntry = {
 | 
			
		||||
        id: `chat-${Date.now()}`,
 | 
			
		||||
        question: question.trim(),
 | 
			
		||||
        answer: aiResponse.answer || 'エラーが発生しました',
 | 
			
		||||
        timestamp: new Date().toISOString(),
 | 
			
		||||
        user: userProfile ? {
 | 
			
		||||
          did: userProfile.did,
 | 
			
		||||
          handle: userProfile.handle,
 | 
			
		||||
          displayName: userProfile.displayName,
 | 
			
		||||
          avatar: userProfile.avatar
 | 
			
		||||
        } : null
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      setChatHistory(prev => [...prev, chatEntry])
 | 
			
		||||
 | 
			
		||||
      // atprotoにレコードを保存
 | 
			
		||||
      await saveChatRecord(chatEntry, aiResponse)
 | 
			
		||||
 | 
			
		||||
      // Dispatch event for blog communication
 | 
			
		||||
      window.dispatchEvent(new CustomEvent('aiResponseReceived', {
 | 
			
		||||
        detail: {
 | 
			
		||||
          question: chatEntry.question,
 | 
			
		||||
          answer: chatEntry.answer,
 | 
			
		||||
          timestamp: chatEntry.timestamp,
 | 
			
		||||
          aiProfile: adminData?.profile ? {
 | 
			
		||||
            did: adminData.did,
 | 
			
		||||
            handle: adminData.profile.handle,
 | 
			
		||||
            displayName: adminData.profile.displayName,
 | 
			
		||||
            avatar: adminData.profile.avatar
 | 
			
		||||
          } : null
 | 
			
		||||
        }
 | 
			
		||||
      }))
 | 
			
		||||
 | 
			
		||||
      return aiResponse
 | 
			
		||||
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      logError(err, 'useAskAI.askQuestion')
 | 
			
		||||
      setError(getErrorMessage(err))
 | 
			
		||||
      throw err
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const saveChatRecord = async (chatEntry, aiResponse) => {
 | 
			
		||||
    if (!agent || !adminData?.did) {
 | 
			
		||||
      logger.warn('Cannot save chat record: missing agent or admin data')
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const currentUrl = window.location.href
 | 
			
		||||
      const timestamp = chatEntry.timestamp
 | 
			
		||||
      const baseRkey = `${new Date(timestamp).toISOString().replace(/[:.]/g, '-').slice(0, -5)}Z`
 | 
			
		||||
 | 
			
		||||
      // Post metadata (共通)
 | 
			
		||||
      const postMetadata = {
 | 
			
		||||
        url: currentUrl,
 | 
			
		||||
        date: timestamp,
 | 
			
		||||
        slug: new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
 | 
			
		||||
        tags: [],
 | 
			
		||||
        title: document.title || 'AI Chat',
 | 
			
		||||
        language: 'ja'
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Question record (ユーザーの質問)
 | 
			
		||||
      const questionRecord = {
 | 
			
		||||
        repo: adminData.did,
 | 
			
		||||
        collection: `${env.collection}.chat`,
 | 
			
		||||
        rkey: baseRkey,
 | 
			
		||||
        record: {
 | 
			
		||||
          $type: `${env.collection}.chat`,
 | 
			
		||||
          post: postMetadata,
 | 
			
		||||
          text: chatEntry.question,
 | 
			
		||||
          type: 'question',
 | 
			
		||||
          author: chatEntry.user ? {
 | 
			
		||||
            did: chatEntry.user.did,
 | 
			
		||||
            handle: chatEntry.user.handle,
 | 
			
		||||
            displayName: chatEntry.user.displayName,
 | 
			
		||||
            avatar: chatEntry.user.avatar
 | 
			
		||||
          } : {
 | 
			
		||||
            did: 'unknown',
 | 
			
		||||
            handle: 'user',
 | 
			
		||||
            displayName: 'User',
 | 
			
		||||
            avatar: null
 | 
			
		||||
          },
 | 
			
		||||
          createdAt: timestamp
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Answer record (AIの回答)
 | 
			
		||||
      const answerRecord = {
 | 
			
		||||
        repo: adminData.did,
 | 
			
		||||
        collection: `${env.collection}.chat`,
 | 
			
		||||
        rkey: `${baseRkey}-answer`,
 | 
			
		||||
        record: {
 | 
			
		||||
          $type: `${env.collection}.chat`,
 | 
			
		||||
          post: postMetadata,
 | 
			
		||||
          text: chatEntry.answer,
 | 
			
		||||
          type: 'answer',
 | 
			
		||||
          author: {
 | 
			
		||||
            did: adminData.did,
 | 
			
		||||
            handle: adminData.profile?.handle || 'ai',
 | 
			
		||||
            displayName: adminData.profile?.displayName || 'ai',
 | 
			
		||||
            avatar: adminData.profile?.avatar || null
 | 
			
		||||
          },
 | 
			
		||||
          createdAt: timestamp
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      logger.log('Saving question record to atproto:', questionRecord)
 | 
			
		||||
      await atproto.putRecord(null, questionRecord, agent)
 | 
			
		||||
 | 
			
		||||
      logger.log('Saving answer record to atproto:', answerRecord)
 | 
			
		||||
      await atproto.putRecord(null, answerRecord, agent)
 | 
			
		||||
 | 
			
		||||
      // キャッシュを無効化
 | 
			
		||||
      collections.invalidateCache(env.collection)
 | 
			
		||||
 | 
			
		||||
      logger.log('Chat records saved successfully')
 | 
			
		||||
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      logError(err, 'useAskAI.saveChatRecord')
 | 
			
		||||
      // 保存エラーは致命的ではないので、UIエラーにはしない
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const clearChatHistory = () => {
 | 
			
		||||
    setChatHistory([])
 | 
			
		||||
    setError(null)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const loadChatHistory = async () => {
 | 
			
		||||
    if (!adminData?.did) return
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const records = await collections.getChat(
 | 
			
		||||
        adminData.apiConfig.pds,
 | 
			
		||||
        adminData.did,
 | 
			
		||||
        env.collection
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      // Group records by timestamp and create Q&A pairs
 | 
			
		||||
      const recordGroups = {}
 | 
			
		||||
      
 | 
			
		||||
      records.forEach(record => {
 | 
			
		||||
        const timestamp = record.value.createdAt
 | 
			
		||||
        const baseKey = timestamp.replace('-answer', '')
 | 
			
		||||
        
 | 
			
		||||
        if (!recordGroups[baseKey]) {
 | 
			
		||||
          recordGroups[baseKey] = {}
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        if (record.value.type === 'question') {
 | 
			
		||||
          recordGroups[baseKey].question = record.value.text
 | 
			
		||||
          recordGroups[baseKey].user = record.value.author
 | 
			
		||||
          recordGroups[baseKey].timestamp = timestamp
 | 
			
		||||
          recordGroups[baseKey].id = record.uri
 | 
			
		||||
        } else if (record.value.type === 'answer') {
 | 
			
		||||
          recordGroups[baseKey].answer = record.value.text
 | 
			
		||||
          recordGroups[baseKey].timestamp = timestamp
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      // Convert to history format, only include complete Q&A pairs
 | 
			
		||||
      const history = Object.values(recordGroups)
 | 
			
		||||
        .filter(group => group.question && group.answer)
 | 
			
		||||
        .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
 | 
			
		||||
        .slice(-10) // 最新10件のみ
 | 
			
		||||
 | 
			
		||||
      setChatHistory(history)
 | 
			
		||||
      logger.log('Chat history loaded:', history.length, 'entries')
 | 
			
		||||
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      logError(err, 'useAskAI.loadChatHistory')
 | 
			
		||||
      // 履歴読み込みエラーは致命的ではない
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    askQuestion,
 | 
			
		||||
    loading,
 | 
			
		||||
    error,
 | 
			
		||||
    chatHistory,
 | 
			
		||||
    clearChatHistory,
 | 
			
		||||
    loadChatHistory
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -24,8 +24,13 @@ export function useUserData(adminData) {
 | 
			
		||||
          env.collection
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        // 2. Get chat records (ai.syui.log.chat doesn't exist, so skip for now)
 | 
			
		||||
        setChatRecords([])
 | 
			
		||||
        // 2. Get chat records from ai.syui.log.chat
 | 
			
		||||
        const chatRecords = await collections.getChat(
 | 
			
		||||
          adminData.apiConfig.pds,
 | 
			
		||||
          adminData.did,
 | 
			
		||||
          env.collection
 | 
			
		||||
        )
 | 
			
		||||
        setChatRecords(chatRecords)
 | 
			
		||||
 | 
			
		||||
        // 3. Get base collection records which contain user comments
 | 
			
		||||
        const baseRecords = await collections.getBase(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,6 @@
 | 
			
		||||
import React from 'react'
 | 
			
		||||
import ReactDOM from 'react-dom/client'
 | 
			
		||||
import App from './App'
 | 
			
		||||
import './App.css'
 | 
			
		||||
 | 
			
		||||
ReactDOM.createRoot(document.getElementById('comment-atproto')).render(<App />)
 | 
			
		||||
							
								
								
									
										206
									
								
								oauth_new/src/utils/avatar.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										206
									
								
								oauth_new/src/utils/avatar.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,206 @@
 | 
			
		||||
import React from 'react'
 | 
			
		||||
import { atproto } from '../api/atproto.js'
 | 
			
		||||
import { getPdsFromHandle, getApiConfig } from './pds.js'
 | 
			
		||||
import { dataCache } from './cache.js'
 | 
			
		||||
import { logError } from './errorHandler.js'
 | 
			
		||||
 | 
			
		||||
// Cache duration for avatar URLs (30 minutes)
 | 
			
		||||
const AVATAR_CACHE_DURATION = 30 * 60 * 1000
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Avatar fetching utility with fallback mechanism
 | 
			
		||||
 * 
 | 
			
		||||
 * Strategy:
 | 
			
		||||
 * 1. First check if avatar exists in the record
 | 
			
		||||
 * 2. If avatar is missing/broken, fetch fresh data from ATProto
 | 
			
		||||
 * 3. Cache results to avoid excessive API calls
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Extract avatar URL from record if available
 | 
			
		||||
 * @param {Object} record - The record object
 | 
			
		||||
 * @returns {string|null} Avatar URL or null
 | 
			
		||||
 */
 | 
			
		||||
function getAvatarFromRecord(record) {
 | 
			
		||||
  const avatar = record?.value?.author?.avatar
 | 
			
		||||
  if (avatar && typeof avatar === 'string' && avatar.startsWith('http')) {
 | 
			
		||||
    return avatar
 | 
			
		||||
  }
 | 
			
		||||
  return null
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Fetch fresh avatar data from ATProto
 | 
			
		||||
 * @param {string} handle - User handle
 | 
			
		||||
 * @param {string} did - User DID (optional, will fetch if not provided)
 | 
			
		||||
 * @returns {Promise<string|null>} Avatar URL or null
 | 
			
		||||
 */
 | 
			
		||||
async function fetchFreshAvatar(handle, did = null) {
 | 
			
		||||
  try {
 | 
			
		||||
    // Step 1: Get PDS from handle
 | 
			
		||||
    const pds = await getPdsFromHandle(handle)
 | 
			
		||||
    const apiConfig = getApiConfig(pds)
 | 
			
		||||
    
 | 
			
		||||
    // Step 2: Get DID if not provided
 | 
			
		||||
    if (!did) {
 | 
			
		||||
      const pdsHost = pds.replace(/^https?:\/\//, '')
 | 
			
		||||
      const repoData = await atproto.getDid(pdsHost, handle)
 | 
			
		||||
      did = repoData
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Step 3: Get profile from bsky API
 | 
			
		||||
    const profile = await atproto.getProfile(apiConfig.bsky, did)
 | 
			
		||||
    
 | 
			
		||||
    // Return avatar URL
 | 
			
		||||
    return profile?.avatar || null
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    logError(error, 'Avatar Fetch')
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get avatar with intelligent fallback
 | 
			
		||||
 * @param {Object} options - Options object
 | 
			
		||||
 * @param {Object} options.record - Record object (optional)
 | 
			
		||||
 * @param {string} options.handle - User handle (required if no record)
 | 
			
		||||
 * @param {string} options.did - User DID (optional)
 | 
			
		||||
 * @param {boolean} options.forceFresh - Force fresh fetch even if cached
 | 
			
		||||
 * @returns {Promise<string|null>} Avatar URL or null
 | 
			
		||||
 */
 | 
			
		||||
export async function getAvatar({ record, handle, did, forceFresh = false }) {
 | 
			
		||||
  // Extract handle and DID from record if available
 | 
			
		||||
  if (record && !handle) {
 | 
			
		||||
    handle = record.value?.author?.handle
 | 
			
		||||
    did = record.value?.author?.did
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  if (!handle) {
 | 
			
		||||
    throw new Error('Handle is required to fetch avatar')
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Generate cache key
 | 
			
		||||
  const cacheKey = `avatar:${handle}`
 | 
			
		||||
  
 | 
			
		||||
  // Check cache first (unless forceFresh)
 | 
			
		||||
  if (!forceFresh) {
 | 
			
		||||
    const cached = dataCache.get(cacheKey)
 | 
			
		||||
    if (cached) {
 | 
			
		||||
      return cached
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Try to get avatar from record first
 | 
			
		||||
  if (record) {
 | 
			
		||||
    const recordAvatar = getAvatarFromRecord(record)
 | 
			
		||||
    if (recordAvatar) {
 | 
			
		||||
      // Validate that the avatar URL is still accessible
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch(recordAvatar, { method: 'HEAD' })
 | 
			
		||||
        if (response.ok) {
 | 
			
		||||
          dataCache.set(cacheKey, recordAvatar, AVATAR_CACHE_DURATION)
 | 
			
		||||
          return recordAvatar
 | 
			
		||||
        }
 | 
			
		||||
      } catch {
 | 
			
		||||
        // Avatar URL is broken, proceed to fetch fresh
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Fetch fresh avatar data
 | 
			
		||||
  const freshAvatar = await fetchFreshAvatar(handle, did)
 | 
			
		||||
  
 | 
			
		||||
  if (freshAvatar) {
 | 
			
		||||
    dataCache.set(cacheKey, freshAvatar, AVATAR_CACHE_DURATION)
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return freshAvatar
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Batch fetch avatars for multiple users
 | 
			
		||||
 * @param {Array<Object>} users - Array of user objects with handle/did
 | 
			
		||||
 * @returns {Promise<Map>} Map of handle -> avatar URL
 | 
			
		||||
 */
 | 
			
		||||
export async function batchFetchAvatars(users) {
 | 
			
		||||
  const avatarMap = new Map()
 | 
			
		||||
  
 | 
			
		||||
  // Process in parallel with concurrency limit
 | 
			
		||||
  const BATCH_SIZE = 5
 | 
			
		||||
  for (let i = 0; i < users.length; i += BATCH_SIZE) {
 | 
			
		||||
    const batch = users.slice(i, i + BATCH_SIZE)
 | 
			
		||||
    const promises = batch.map(async (user) => {
 | 
			
		||||
      const avatar = await getAvatar({
 | 
			
		||||
        handle: user.handle,
 | 
			
		||||
        did: user.did
 | 
			
		||||
      })
 | 
			
		||||
      return { handle: user.handle, avatar }
 | 
			
		||||
    })
 | 
			
		||||
    
 | 
			
		||||
    const results = await Promise.all(promises)
 | 
			
		||||
    results.forEach(({ handle, avatar }) => {
 | 
			
		||||
      avatarMap.set(handle, avatar)
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return avatarMap
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Prefetch and cache avatar for a handle
 | 
			
		||||
 * @param {string} handle - User handle
 | 
			
		||||
 * @returns {Promise<void>}
 | 
			
		||||
 */
 | 
			
		||||
export async function prefetchAvatar(handle) {
 | 
			
		||||
  await getAvatar({ handle })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Clear avatar cache for a specific handle
 | 
			
		||||
 * @param {string} handle - User handle
 | 
			
		||||
 */
 | 
			
		||||
export function clearAvatarCache(handle) {
 | 
			
		||||
  if (handle) {
 | 
			
		||||
    dataCache.delete(`avatar:${handle}`)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Clear all avatar caches
 | 
			
		||||
 */
 | 
			
		||||
export function clearAllAvatarCaches() {
 | 
			
		||||
  dataCache.invalidatePattern('avatar:')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * React hook for avatar management
 | 
			
		||||
 * @param {Object} options - Options for avatar fetching
 | 
			
		||||
 * @returns {Object} { avatar, loading, error, refetch }
 | 
			
		||||
 */
 | 
			
		||||
export function useAvatar({ record, handle, did }) {
 | 
			
		||||
  const [state, setState] = React.useState({
 | 
			
		||||
    avatar: null,
 | 
			
		||||
    loading: true,
 | 
			
		||||
    error: null
 | 
			
		||||
  })
 | 
			
		||||
  
 | 
			
		||||
  const fetchAvatar = React.useCallback(async (forceFresh = false) => {
 | 
			
		||||
    setState(prev => ({ ...prev, loading: true, error: null }))
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      const avatarUrl = await getAvatar({ record, handle, did, forceFresh })
 | 
			
		||||
      setState({ avatar: avatarUrl, loading: false, error: null })
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      setState({ avatar: null, loading: false, error: error.message })
 | 
			
		||||
    }
 | 
			
		||||
  }, [record, handle, did])
 | 
			
		||||
  
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    fetchAvatar()
 | 
			
		||||
  }, [fetchAvatar])
 | 
			
		||||
  
 | 
			
		||||
  return {
 | 
			
		||||
    ...state,
 | 
			
		||||
    refetch: () => fetchAvatar(true)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										262
									
								
								oauth_new/src/utils/avatarCache.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										262
									
								
								oauth_new/src/utils/avatarCache.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,262 @@
 | 
			
		||||
import { dataCache } from './cache.js'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Avatar-specific cache utilities
 | 
			
		||||
 * Extends the base cache system with avatar-specific functionality
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
// Cache keys
 | 
			
		||||
const CACHE_PREFIX = 'avatar:'
 | 
			
		||||
const METADATA_KEY = 'avatar:metadata'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get cache metadata for avatars
 | 
			
		||||
 * @returns {Object} Metadata about avatar cache
 | 
			
		||||
 */
 | 
			
		||||
export function getAvatarCacheMetadata() {
 | 
			
		||||
  return dataCache.get(METADATA_KEY) || {
 | 
			
		||||
    totalCount: 0,
 | 
			
		||||
    lastCleanup: Date.now(),
 | 
			
		||||
    cacheHits: 0,
 | 
			
		||||
    cacheMisses: 0
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Update cache metadata
 | 
			
		||||
 * @param {Object} updates - Updates to apply to metadata
 | 
			
		||||
 */
 | 
			
		||||
function updateMetadata(updates) {
 | 
			
		||||
  const current = getAvatarCacheMetadata()
 | 
			
		||||
  const updated = { ...current, ...updates }
 | 
			
		||||
  dataCache.set(METADATA_KEY, updated)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Track cache hit
 | 
			
		||||
 */
 | 
			
		||||
export function trackCacheHit() {
 | 
			
		||||
  const metadata = getAvatarCacheMetadata()
 | 
			
		||||
  updateMetadata({ cacheHits: metadata.cacheHits + 1 })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Track cache miss
 | 
			
		||||
 */
 | 
			
		||||
export function trackCacheMiss() {
 | 
			
		||||
  const metadata = getAvatarCacheMetadata()
 | 
			
		||||
  updateMetadata({ cacheMisses: metadata.cacheMisses + 1 })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get all cached avatar handles
 | 
			
		||||
 * @returns {Array<string>} List of cached handles
 | 
			
		||||
 */
 | 
			
		||||
export function getCachedAvatarHandles() {
 | 
			
		||||
  // This would require enumerating cache keys
 | 
			
		||||
  // For now, we'll track this in metadata
 | 
			
		||||
  const metadata = getAvatarCacheMetadata()
 | 
			
		||||
  return metadata.handles || []
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Add handle to cached list
 | 
			
		||||
 * @param {string} handle - Handle to add
 | 
			
		||||
 */
 | 
			
		||||
export function addCachedHandle(handle) {
 | 
			
		||||
  const metadata = getAvatarCacheMetadata()
 | 
			
		||||
  const handles = metadata.handles || []
 | 
			
		||||
  if (!handles.includes(handle)) {
 | 
			
		||||
    handles.push(handle)
 | 
			
		||||
    updateMetadata({ 
 | 
			
		||||
      handles,
 | 
			
		||||
      totalCount: handles.length
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Remove handle from cached list
 | 
			
		||||
 * @param {string} handle - Handle to remove
 | 
			
		||||
 */
 | 
			
		||||
export function removeCachedHandle(handle) {
 | 
			
		||||
  const metadata = getAvatarCacheMetadata()
 | 
			
		||||
  const handles = (metadata.handles || []).filter(h => h !== handle)
 | 
			
		||||
  updateMetadata({ 
 | 
			
		||||
    handles,
 | 
			
		||||
    totalCount: handles.length
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Clean up expired avatar cache entries
 | 
			
		||||
 * @param {number} maxAge - Maximum age in milliseconds (default: 30 minutes)
 | 
			
		||||
 * @returns {number} Number of entries cleaned
 | 
			
		||||
 */
 | 
			
		||||
export function cleanupExpiredAvatars(maxAge = 30 * 60 * 1000) {
 | 
			
		||||
  const now = Date.now()
 | 
			
		||||
  const metadata = getAvatarCacheMetadata()
 | 
			
		||||
  const handles = metadata.handles || []
 | 
			
		||||
  let cleanedCount = 0
 | 
			
		||||
 | 
			
		||||
  handles.forEach(handle => {
 | 
			
		||||
    const cacheKey = `${CACHE_PREFIX}${handle}`
 | 
			
		||||
    const entry = dataCache.get(cacheKey, true) // Get with metadata
 | 
			
		||||
    
 | 
			
		||||
    if (entry && entry.timestamp && (now - entry.timestamp) > maxAge) {
 | 
			
		||||
      dataCache.delete(cacheKey)
 | 
			
		||||
      cleanedCount++
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // Update metadata
 | 
			
		||||
  if (cleanedCount > 0) {
 | 
			
		||||
    const remainingHandles = handles.filter(handle => {
 | 
			
		||||
      const cacheKey = `${CACHE_PREFIX}${handle}`
 | 
			
		||||
      return dataCache.get(cacheKey) !== null
 | 
			
		||||
    })
 | 
			
		||||
    
 | 
			
		||||
    updateMetadata({
 | 
			
		||||
      handles: remainingHandles,
 | 
			
		||||
      totalCount: remainingHandles.length,
 | 
			
		||||
      lastCleanup: now
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return cleanedCount
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get cache statistics
 | 
			
		||||
 * @returns {Object} Cache statistics
 | 
			
		||||
 */
 | 
			
		||||
export function getAvatarCacheStats() {
 | 
			
		||||
  const metadata = getAvatarCacheMetadata()
 | 
			
		||||
  const totalRequests = metadata.cacheHits + metadata.cacheMisses
 | 
			
		||||
  const hitRate = totalRequests > 0 ? (metadata.cacheHits / totalRequests * 100) : 0
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    totalCached: metadata.totalCount || 0,
 | 
			
		||||
    cacheHits: metadata.cacheHits || 0,
 | 
			
		||||
    cacheMisses: metadata.cacheMisses || 0,
 | 
			
		||||
    hitRate: Math.round(hitRate * 100) / 100,
 | 
			
		||||
    lastCleanup: metadata.lastCleanup ? new Date(metadata.lastCleanup) : null
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Clear all avatar cache data
 | 
			
		||||
 * @returns {number} Number of entries cleared
 | 
			
		||||
 */
 | 
			
		||||
export function clearAllAvatarCache() {
 | 
			
		||||
  const metadata = getAvatarCacheMetadata()
 | 
			
		||||
  const handles = metadata.handles || []
 | 
			
		||||
  
 | 
			
		||||
  handles.forEach(handle => {
 | 
			
		||||
    const cacheKey = `${CACHE_PREFIX}${handle}`
 | 
			
		||||
    dataCache.delete(cacheKey)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // Clear metadata
 | 
			
		||||
  dataCache.delete(METADATA_KEY)
 | 
			
		||||
  
 | 
			
		||||
  return handles.length
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Preload avatars for a list of handles
 | 
			
		||||
 * @param {Array<string>} handles - Handles to preload
 | 
			
		||||
 * @param {Function} getAvatar - Avatar fetching function
 | 
			
		||||
 * @returns {Promise<Map>} Map of handle -> avatar URL results
 | 
			
		||||
 */
 | 
			
		||||
export async function preloadAvatars(handles, getAvatar) {
 | 
			
		||||
  const results = new Map()
 | 
			
		||||
  const BATCH_SIZE = 3 // Smaller batch for preloading
 | 
			
		||||
 | 
			
		||||
  for (let i = 0; i < handles.length; i += BATCH_SIZE) {
 | 
			
		||||
    const batch = handles.slice(i, i + BATCH_SIZE)
 | 
			
		||||
    const promises = batch.map(async (handle) => {
 | 
			
		||||
      try {
 | 
			
		||||
        const avatar = await getAvatar({ handle })
 | 
			
		||||
        return { handle, avatar, success: true }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        return { handle, avatar: null, success: false, error: error.message }
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const batchResults = await Promise.all(promises)
 | 
			
		||||
    batchResults.forEach(({ handle, avatar, success }) => {
 | 
			
		||||
      results.set(handle, { avatar, success })
 | 
			
		||||
      if (success) {
 | 
			
		||||
        addCachedHandle(handle)
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    // Small delay between batches to avoid overwhelming the API
 | 
			
		||||
    if (i + BATCH_SIZE < handles.length) {
 | 
			
		||||
      await new Promise(resolve => setTimeout(resolve, 100))
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return results
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Validate cached avatar URLs
 | 
			
		||||
 * Check if cached avatar URLs are still valid
 | 
			
		||||
 * @param {number} sampleSize - Number of cached avatars to validate (default: 5)
 | 
			
		||||
 * @returns {Promise<Object>} Validation results
 | 
			
		||||
 */
 | 
			
		||||
export async function validateCachedAvatars(sampleSize = 5) {
 | 
			
		||||
  const metadata = getAvatarCacheMetadata()
 | 
			
		||||
  const handles = metadata.handles || []
 | 
			
		||||
  
 | 
			
		||||
  if (handles.length === 0) {
 | 
			
		||||
    return { validCount: 0, invalidCount: 0, totalChecked: 0 }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Sample random handles to check
 | 
			
		||||
  const samplesToCheck = handles
 | 
			
		||||
    .sort(() => Math.random() - 0.5)
 | 
			
		||||
    .slice(0, sampleSize)
 | 
			
		||||
 | 
			
		||||
  let validCount = 0
 | 
			
		||||
  let invalidCount = 0
 | 
			
		||||
 | 
			
		||||
  for (const handle of samplesToCheck) {
 | 
			
		||||
    const cacheKey = `${CACHE_PREFIX}${handle}`
 | 
			
		||||
    const avatarUrl = dataCache.get(cacheKey)
 | 
			
		||||
    
 | 
			
		||||
    if (avatarUrl && typeof avatarUrl === 'string' && avatarUrl.startsWith('http')) {
 | 
			
		||||
      try {
 | 
			
		||||
        const response = await fetch(avatarUrl, { method: 'HEAD' })
 | 
			
		||||
        if (response.ok) {
 | 
			
		||||
          validCount++
 | 
			
		||||
        } else {
 | 
			
		||||
          invalidCount++
 | 
			
		||||
          // Remove invalid cached avatar
 | 
			
		||||
          dataCache.delete(cacheKey)
 | 
			
		||||
          removeCachedHandle(handle)
 | 
			
		||||
        }
 | 
			
		||||
      } catch {
 | 
			
		||||
        invalidCount++
 | 
			
		||||
        // Remove invalid cached avatar
 | 
			
		||||
        dataCache.delete(cacheKey)
 | 
			
		||||
        removeCachedHandle(handle)
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      invalidCount++
 | 
			
		||||
      // Remove invalid cache entry
 | 
			
		||||
      dataCache.delete(cacheKey)
 | 
			
		||||
      removeCachedHandle(handle)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    validCount,
 | 
			
		||||
    invalidCount,
 | 
			
		||||
    totalChecked: samplesToCheck.length,
 | 
			
		||||
    validationRate: samplesToCheck.length > 0 ? 
 | 
			
		||||
      Math.round((validCount / samplesToCheck.length) * 100) : 0
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										143
									
								
								oauth_new/src/utils/avatarFetcher.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								oauth_new/src/utils/avatarFetcher.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,143 @@
 | 
			
		||||
import { getPdsFromHandle, getApiConfig } from './pds.js'
 | 
			
		||||
import { logger } from './logger.js'
 | 
			
		||||
 | 
			
		||||
// Avatar取得の状態管理
 | 
			
		||||
const avatarCache = new Map()
 | 
			
		||||
const CACHE_DURATION = 30 * 60 * 1000 // 30分
 | 
			
		||||
 | 
			
		||||
// Avatar URLが有効かチェック
 | 
			
		||||
async function isAvatarValid(avatarUrl) {
 | 
			
		||||
  if (!avatarUrl) return false
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch(avatarUrl, { method: 'HEAD' })
 | 
			
		||||
    return response.ok
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    logger.warn('Avatar URL check failed:', error)
 | 
			
		||||
    return false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// handleからDIDを取得
 | 
			
		||||
async function getDid(handle) {
 | 
			
		||||
  try {
 | 
			
		||||
    const pds = await getPdsFromHandle(handle)
 | 
			
		||||
    const response = await fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`)
 | 
			
		||||
    const data = await response.json()
 | 
			
		||||
    return data.did
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    logger.error('Failed to get DID for handle:', handle, error)
 | 
			
		||||
    throw error
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DIDからプロフィール情報を取得
 | 
			
		||||
async function getProfile(did, handle) {
 | 
			
		||||
  try {
 | 
			
		||||
    const pds = await getPdsFromHandle(handle)
 | 
			
		||||
    const apiConfig = getApiConfig(pds)
 | 
			
		||||
    
 | 
			
		||||
    logger.log('Getting profile for DID:', did, 'using API:', apiConfig.bsky)
 | 
			
		||||
    const response = await fetch(`${apiConfig.bsky}/xrpc/app.bsky.actor.getProfile?actor=${did}`)
 | 
			
		||||
    
 | 
			
		||||
    if (!response.ok) {
 | 
			
		||||
      throw new Error(`Profile API error: ${response.status} ${response.statusText}`)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const data = await response.json()
 | 
			
		||||
    logger.log('Profile data received:', data)
 | 
			
		||||
    return data
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    logger.error('Failed to get profile for DID:', did, error)
 | 
			
		||||
    throw error
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 新しいavatar URLを取得
 | 
			
		||||
async function fetchFreshAvatar(handle, did) {
 | 
			
		||||
  const cacheKey = `${handle}:${did || 'no-did'}`
 | 
			
		||||
  const cached = avatarCache.get(cacheKey)
 | 
			
		||||
  
 | 
			
		||||
  // キャッシュチェック
 | 
			
		||||
  if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
 | 
			
		||||
    logger.log('Using cached avatar for:', handle)
 | 
			
		||||
    return cached.avatar
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    logger.log('Fetching fresh avatar for handle:', handle, 'with DID:', did)
 | 
			
		||||
    
 | 
			
		||||
    // DIDが不明な場合は取得
 | 
			
		||||
    let actualDid = did
 | 
			
		||||
    if (!actualDid) {
 | 
			
		||||
      logger.log('No DID provided, fetching from handle:', handle)
 | 
			
		||||
      actualDid = await getDid(handle)
 | 
			
		||||
      logger.log('Got DID from handle:', actualDid)
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // プロフィール取得
 | 
			
		||||
    const profile = await getProfile(actualDid, handle)
 | 
			
		||||
    const avatarUrl = profile.avatar || null
 | 
			
		||||
    
 | 
			
		||||
    // キャッシュに保存
 | 
			
		||||
    avatarCache.set(cacheKey, {
 | 
			
		||||
      avatar: avatarUrl,
 | 
			
		||||
      timestamp: Date.now(),
 | 
			
		||||
      profile: {
 | 
			
		||||
        displayName: profile.displayName,
 | 
			
		||||
        handle: profile.handle
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    
 | 
			
		||||
    logger.log('Fresh avatar fetched for:', handle, 'Avatar URL:', avatarUrl)
 | 
			
		||||
    return avatarUrl
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    logger.error('Failed to fetch fresh avatar for:', handle, 'Error:', error)
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// メイン関数: avatarを取得(recordから → 新規取得)
 | 
			
		||||
export async function getValidAvatar(record) {
 | 
			
		||||
  const author = record?.value?.author
 | 
			
		||||
  if (!author?.handle) {
 | 
			
		||||
    logger.warn('No handle found in record author')
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { handle, did, avatar: recordAvatar } = author
 | 
			
		||||
 | 
			
		||||
  // 1. record内のavatarをチェック
 | 
			
		||||
  if (recordAvatar) {
 | 
			
		||||
    const isValid = await isAvatarValid(recordAvatar)
 | 
			
		||||
    if (isValid) {
 | 
			
		||||
      logger.log('Using avatar from record:', recordAvatar)
 | 
			
		||||
      return recordAvatar
 | 
			
		||||
    } else {
 | 
			
		||||
      logger.log('Record avatar is broken, fetching fresh:', recordAvatar)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 2. 新しいavatarを取得
 | 
			
		||||
  return await fetchFreshAvatar(handle, did)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// キャッシュクリア
 | 
			
		||||
export function clearAvatarCache() {
 | 
			
		||||
  avatarCache.clear()
 | 
			
		||||
  logger.log('Avatar cache cleared')
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// キャッシュ統計
 | 
			
		||||
export function getAvatarCacheStats() {
 | 
			
		||||
  return {
 | 
			
		||||
    size: avatarCache.size,
 | 
			
		||||
    entries: Array.from(avatarCache.entries()).map(([key, value]) => ({
 | 
			
		||||
      key,
 | 
			
		||||
      avatar: value.avatar,
 | 
			
		||||
      age: Date.now() - value.timestamp,
 | 
			
		||||
      profile: value.profile
 | 
			
		||||
    }))
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										63
									
								
								oauth_new/src/utils/cache.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								oauth_new/src/utils/cache.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,63 @@
 | 
			
		||||
import { logger } from './logger.js'
 | 
			
		||||
 | 
			
		||||
class SimpleCache {
 | 
			
		||||
  constructor(ttl = 30000) { // 30秒TTL
 | 
			
		||||
    this.cache = new Map()
 | 
			
		||||
    this.ttl = ttl
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  generateKey(...parts) {
 | 
			
		||||
    return parts.filter(Boolean).join(':')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get(key) {
 | 
			
		||||
    const item = this.cache.get(key)
 | 
			
		||||
    if (!item) return null
 | 
			
		||||
    
 | 
			
		||||
    if (Date.now() - item.timestamp > this.ttl) {
 | 
			
		||||
      this.cache.delete(key)
 | 
			
		||||
      return null
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    logger.log(`Cache hit: ${key}`)
 | 
			
		||||
    return item.data
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set(key, data) {
 | 
			
		||||
    this.cache.set(key, {
 | 
			
		||||
      data,
 | 
			
		||||
      timestamp: Date.now()
 | 
			
		||||
    })
 | 
			
		||||
    logger.log(`Cache set: ${key}`)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clear() {
 | 
			
		||||
    this.cache.clear()
 | 
			
		||||
    logger.log('Cache cleared')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  invalidatePattern(pattern) {
 | 
			
		||||
    let deletedCount = 0
 | 
			
		||||
    for (const key of this.cache.keys()) {
 | 
			
		||||
      if (key.includes(pattern)) {
 | 
			
		||||
        this.cache.delete(key)
 | 
			
		||||
        deletedCount++
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    logger.log(`Cache invalidated: ${pattern} (${deletedCount} items)`)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getStats() {
 | 
			
		||||
    return {
 | 
			
		||||
      size: this.cache.size,
 | 
			
		||||
      keys: Array.from(this.cache.keys())
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const dataCache = new SimpleCache()
 | 
			
		||||
 | 
			
		||||
// デバッグ用:開発環境でのみグローバルからアクセス可能にする
 | 
			
		||||
if (import.meta.env.DEV) {
 | 
			
		||||
  window.dataCache = dataCache
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										49
									
								
								oauth_new/src/utils/errorHandler.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								oauth_new/src/utils/errorHandler.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
			
		||||
import { logger } from './logger.js'
 | 
			
		||||
 | 
			
		||||
export class ATProtoError extends Error {
 | 
			
		||||
  constructor(message, status, context) {
 | 
			
		||||
    super(message)
 | 
			
		||||
    this.status = status
 | 
			
		||||
    this.context = context
 | 
			
		||||
    this.timestamp = new Date().toISOString()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getErrorMessage(error) {
 | 
			
		||||
  if (!error) return '不明なエラー'
 | 
			
		||||
  
 | 
			
		||||
  if (error.status === 400) {
 | 
			
		||||
    return 'アカウントまたはレコードが見つかりません'
 | 
			
		||||
  } else if (error.status === 401) {
 | 
			
		||||
    return '認証が必要です。ログインしてください'
 | 
			
		||||
  } else if (error.status === 403) {
 | 
			
		||||
    return 'アクセス権限がありません'
 | 
			
		||||
  } else if (error.status === 429) {
 | 
			
		||||
    return 'アクセスが集中しています。しばらく待ってから再試行してください'
 | 
			
		||||
  } else if (error.status === 500) {
 | 
			
		||||
    return 'サーバーでエラーが発生しました'
 | 
			
		||||
  } else if (error.message?.includes('fetch')) {
 | 
			
		||||
    return 'ネットワーク接続を確認してください'
 | 
			
		||||
  } else if (error.message?.includes('timeout')) {
 | 
			
		||||
    return 'タイムアウトしました。再試行してください'
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  return `エラーが発生しました: ${error.message || '不明'}`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function logError(error, context = 'Unknown') {
 | 
			
		||||
  const errorInfo = {
 | 
			
		||||
    context,
 | 
			
		||||
    message: error.message,
 | 
			
		||||
    status: error.status,
 | 
			
		||||
    timestamp: new Date().toISOString(),
 | 
			
		||||
    url: window.location.href
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  logger.error(`[ATProto Error] ${context}:`, errorInfo)
 | 
			
		||||
  
 | 
			
		||||
  // 本番環境では外部ログサービスに送信することも可能
 | 
			
		||||
  // if (import.meta.env.PROD) {
 | 
			
		||||
  //   sendToLogService(errorInfo)
 | 
			
		||||
  // }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										81
									
								
								oauth_new/src/utils/logger.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								oauth_new/src/utils/logger.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,81 @@
 | 
			
		||||
// Logger utility with environment-based control
 | 
			
		||||
class Logger {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.isDev = import.meta.env.DEV || false
 | 
			
		||||
    this.isEnabled = this.isDev // Only enable in development
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  log(...args) {
 | 
			
		||||
    if (this.isEnabled) {
 | 
			
		||||
      console.log(...args)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  error(...args) {
 | 
			
		||||
    if (this.isEnabled) {
 | 
			
		||||
      console.error(...args)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  warn(...args) {
 | 
			
		||||
    if (this.isEnabled) {
 | 
			
		||||
      console.warn(...args)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  info(...args) {
 | 
			
		||||
    if (this.isEnabled) {
 | 
			
		||||
      console.info(...args)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // グループログ
 | 
			
		||||
  group(label) {
 | 
			
		||||
    if (this.isEnabled) {
 | 
			
		||||
      console.group(label)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  groupEnd() {
 | 
			
		||||
    if (this.isEnabled) {
 | 
			
		||||
      console.groupEnd()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // テーブル表示
 | 
			
		||||
  table(data) {
 | 
			
		||||
    if (this.isEnabled) {
 | 
			
		||||
      console.table(data)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 時間計測
 | 
			
		||||
  time(label) {
 | 
			
		||||
    if (this.isEnabled) {
 | 
			
		||||
      console.time(label)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  timeEnd(label) {
 | 
			
		||||
    if (this.isEnabled) {
 | 
			
		||||
      console.timeEnd(label)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // ログを有効/無効にする
 | 
			
		||||
  enable() {
 | 
			
		||||
    this.isEnabled = true
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  disable() {
 | 
			
		||||
    this.isEnabled = false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// シングルトンインスタンス
 | 
			
		||||
export const logger = new Logger()
 | 
			
		||||
 | 
			
		||||
// 開発環境でのみグローバルアクセス可能にする
 | 
			
		||||
if (import.meta.env.DEV) {
 | 
			
		||||
  window._logger = logger
 | 
			
		||||
}
 | 
			
		||||
@@ -64,6 +64,10 @@ case "${1:-serve}" in
 | 
			
		||||
	oauth|o)
 | 
			
		||||
		_oauth_build
 | 
			
		||||
		;;
 | 
			
		||||
	n)
 | 
			
		||||
		oauth=$d/oauth_new
 | 
			
		||||
		_oauth_build
 | 
			
		||||
		;;
 | 
			
		||||
	comment|co)
 | 
			
		||||
		_server_comment
 | 
			
		||||
		;;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user