cleanup docs
This commit is contained in:
		| @@ -1,116 +0,0 @@ | ||||
| # 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 が無効化される | ||||
| @@ -1,174 +0,0 @@ | ||||
| # 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 | ||||
| @@ -1,420 +0,0 @@ | ||||
| # 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. | ||||
| @@ -1,57 +0,0 @@ | ||||
| name: Deploy to Cloudflare Pages | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|   workflow_dispatch: | ||||
|  | ||||
| env: | ||||
|   OAUTH_DIR: oauth_new | ||||
|  | ||||
| jobs: | ||||
|   deploy: | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       contents: read | ||||
|       deployments: write | ||||
|      | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|  | ||||
|       - name: Setup Node.js | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: '20' | ||||
|           cache: 'npm' | ||||
|           cache-dependency-path: ${{ env.OAUTH_DIR }}/package-lock.json | ||||
|  | ||||
|       - name: Install dependencies | ||||
|         run: | | ||||
|           cd ${{ env.OAUTH_DIR }} | ||||
|           npm ci | ||||
|  | ||||
|       - name: Build OAuth app | ||||
|         run: | | ||||
|           cd ${{ env.OAUTH_DIR }} | ||||
|           NODE_ENV=production npm run build | ||||
|         env: | ||||
|           VITE_ADMIN: ${{ secrets.VITE_ADMIN }} | ||||
|           VITE_PDS: ${{ secrets.VITE_PDS }} | ||||
|           VITE_HANDLE_LIST: ${{ secrets.VITE_HANDLE_LIST }} | ||||
|           VITE_COLLECTION: ${{ secrets.VITE_COLLECTION }} | ||||
|           VITE_OAUTH_CLIENT_ID: ${{ secrets.VITE_OAUTH_CLIENT_ID }} | ||||
|           VITE_OAUTH_REDIRECT_URI: ${{ secrets.VITE_OAUTH_REDIRECT_URI }} | ||||
|           VITE_ENABLE_TEST_UI: 'false' | ||||
|           VITE_ENABLE_DEBUG: 'false' | ||||
|  | ||||
|       - name: Deploy to Cloudflare Pages | ||||
|         uses: cloudflare/pages-action@v1 | ||||
|         with: | ||||
|           apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||||
|           accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||||
|           projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }} | ||||
|           directory: ${{ env.OAUTH_DIR }}/dist | ||||
|           gitHubToken: ${{ secrets.GITHUB_TOKEN }} | ||||
|           deploymentName: Production | ||||
| @@ -1,104 +0,0 @@ | ||||
| name: Deploy to Cloudflare Pages | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|   workflow_dispatch: | ||||
|  | ||||
| env: | ||||
|   OAUTH_DIR: oauth_new | ||||
|   KEEP_DEPLOYMENTS: 5  # 保持するデプロイメント数 | ||||
|  | ||||
| jobs: | ||||
|   deploy: | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       contents: read | ||||
|       deployments: write | ||||
|      | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|  | ||||
|       - name: Setup Node.js | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: '20' | ||||
|           cache: 'npm' | ||||
|           cache-dependency-path: ${{ env.OAUTH_DIR }}/package-lock.json | ||||
|  | ||||
|       - name: Install dependencies | ||||
|         run: | | ||||
|           cd ${{ env.OAUTH_DIR }} | ||||
|           npm ci | ||||
|  | ||||
|       - name: Build OAuth app | ||||
|         run: | | ||||
|           cd ${{ env.OAUTH_DIR }} | ||||
|           NODE_ENV=production npm run build | ||||
|         env: | ||||
|           VITE_ADMIN: ${{ secrets.VITE_ADMIN }} | ||||
|           VITE_PDS: ${{ secrets.VITE_PDS }} | ||||
|           VITE_HANDLE_LIST: ${{ secrets.VITE_HANDLE_LIST }} | ||||
|           VITE_COLLECTION: ${{ secrets.VITE_COLLECTION }} | ||||
|           VITE_OAUTH_CLIENT_ID: ${{ secrets.VITE_OAUTH_CLIENT_ID }} | ||||
|           VITE_OAUTH_REDIRECT_URI: ${{ secrets.VITE_OAUTH_REDIRECT_URI }} | ||||
|           VITE_ENABLE_TEST_UI: 'false' | ||||
|           VITE_ENABLE_DEBUG: 'false' | ||||
|  | ||||
|       - name: Deploy to Cloudflare Pages | ||||
|         uses: cloudflare/pages-action@v1 | ||||
|         with: | ||||
|           apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||||
|           accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||||
|           projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }} | ||||
|           directory: ${{ env.OAUTH_DIR }}/dist | ||||
|           gitHubToken: ${{ secrets.GITHUB_TOKEN }} | ||||
|           deploymentName: Production | ||||
|  | ||||
|   cleanup: | ||||
|     needs: deploy | ||||
|     runs-on: ubuntu-latest | ||||
|     if: success() | ||||
|      | ||||
|     steps: | ||||
|       - name: Wait for deployment to complete | ||||
|         run: sleep 30 | ||||
|          | ||||
|       - name: Cleanup old deployments | ||||
|         run: | | ||||
|           # Get all deployments | ||||
|           DEPLOYMENTS=$(curl -s -X GET \ | ||||
|             "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \ | ||||
|             -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ | ||||
|             -H "Content-Type: application/json") | ||||
|            | ||||
|           # Extract deployment IDs (skip the latest N deployments) | ||||
|           DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty") | ||||
|            | ||||
|           if [ -z "$DEPLOYMENT_IDS" ]; then | ||||
|             echo "No old deployments to delete" | ||||
|             exit 0 | ||||
|           fi | ||||
|            | ||||
|           # Delete old deployments | ||||
|           for ID in $DEPLOYMENT_IDS; do | ||||
|             echo "Deleting deployment: $ID" | ||||
|             RESPONSE=$(curl -s -X DELETE \ | ||||
|               "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \ | ||||
|               -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ | ||||
|               -H "Content-Type: application/json") | ||||
|              | ||||
|             SUCCESS=$(echo "$RESPONSE" | jq -r '.success') | ||||
|             if [ "$SUCCESS" = "true" ]; then | ||||
|               echo "Successfully deleted deployment: $ID" | ||||
|             else | ||||
|               echo "Failed to delete deployment: $ID" | ||||
|               echo "$RESPONSE" | jq . | ||||
|             fi | ||||
|              | ||||
|             sleep 1  # Rate limiting | ||||
|           done | ||||
|            | ||||
|           echo "Cleanup completed!" | ||||
| @@ -1,178 +0,0 @@ | ||||
| # 本番環境デプロイメント手順 | ||||
|  | ||||
| ## 本番環境用の調整 | ||||
|  | ||||
| ### 1. テスト機能の削除・無効化 | ||||
|  | ||||
| 本番環境では以下の調整が必要です: | ||||
|  | ||||
| #### A. TestUI コンポーネントの削除 | ||||
| ```jsx | ||||
| // src/App.jsx から以下を削除/コメントアウト | ||||
| import TestUI from './components/TestUI.jsx' | ||||
| const [showTestUI, setShowTestUI] = useState(false) | ||||
|  | ||||
| // ボトムセクションからTestUIを削除 | ||||
| {showTestUI && ( | ||||
|   <TestUI /> | ||||
| )} | ||||
| <button | ||||
|   onClick={() => setShowTestUI(!showTestUI)} | ||||
|   className={`btn ${showTestUI ? 'btn-danger' : 'btn-outline'} btn-sm`} | ||||
| > | ||||
|   {showTestUI ? 'close test' : 'test'} | ||||
| </button> | ||||
| ``` | ||||
|  | ||||
| #### B. ログ出力の完全無効化 | ||||
| 現在は `logger.js` で開発環境のみログが有効になっていますが、完全に確実にするため: | ||||
|  | ||||
| ```bash | ||||
| # 本番ビルド前に全てのconsole.logを確認 | ||||
| grep -r "console\." src/ --exclude-dir=node_modules | ||||
| ``` | ||||
|  | ||||
| ### 2. 環境変数の設定 | ||||
|  | ||||
| #### 本番用 .env.production | ||||
| ```bash | ||||
| VITE_ATPROTO_PDS=syu.is | ||||
| VITE_ADMIN_HANDLE=ai.syui.ai   | ||||
| VITE_AI_HANDLE=ai.syui.ai | ||||
| VITE_OAUTH_COLLECTION=ai.syui.log | ||||
| ``` | ||||
|  | ||||
| ### 3. ビルドコマンド | ||||
|  | ||||
| ```bash | ||||
| # 本番用ビルド | ||||
| npm run build | ||||
|  | ||||
| # 生成されるファイル確認 | ||||
| ls -la dist/ | ||||
| ``` | ||||
|  | ||||
| ### 4. デプロイ用ファイル構成 | ||||
|  | ||||
| ``` | ||||
| dist/ | ||||
| ├── index.html                          # 最小化HTML | ||||
| ├── assets/ | ||||
| │   ├── comment-atproto-[hash].js      # メインJSバンドル | ||||
| │   └── comment-atproto-[hash].css     # CSS | ||||
| ``` | ||||
|  | ||||
| ### 5. ailog サイトへの統合 | ||||
|  | ||||
| #### A. アセットファイルのコピー | ||||
| ```bash | ||||
| # distファイルをailogサイトの適切な場所にコピー | ||||
| cp dist/assets/* /path/to/ailog/static/assets/ | ||||
| cp dist/index.html /path/to/ailog/templates/oauth-assets.html | ||||
| ``` | ||||
|  | ||||
| #### B. ailog テンプレートでの読み込み | ||||
| ```html | ||||
| <!-- ailog のテンプレートに追加 --> | ||||
| {{ if .Site.Params.oauth_comments }} | ||||
|   {{ partial "oauth-assets.html" . }} | ||||
| {{ end }} | ||||
| ``` | ||||
|  | ||||
| ### 6. 本番環境チェックリスト | ||||
|  | ||||
| #### ✅ セキュリティ | ||||
| - [ ] OAuth認証のリダイレクトURL確認 | ||||
| - [ ] 環境変数の機密情報確認 | ||||
| - [ ] HTTPS通信確認 | ||||
|  | ||||
| #### ✅ パフォーマンス   | ||||
| - [ ] バンドルサイズ確認(現在1.2MB) | ||||
| - [ ] ファイル圧縮確認 | ||||
| - [ ] キャッシュ設定確認 | ||||
|  | ||||
| #### ✅ 機能 | ||||
| - [ ] 本番PDS接続確認 | ||||
| - [ ] OAuth認証フロー確認 | ||||
| - [ ] コメント投稿・表示確認 | ||||
| - [ ] アバター表示確認 | ||||
|  | ||||
| #### ✅ UI/UX | ||||
| - [ ] モバイル表示確認 | ||||
| - [ ] アクセシビリティ確認 | ||||
| - [ ] エラーハンドリング確認 | ||||
|  | ||||
| ### 7. 段階的デプロイ戦略 | ||||
|  | ||||
| #### Phase 1: テスト環境 | ||||
| ```bash | ||||
| # テスト用のサブドメインでデプロイ | ||||
| # test.syui.ai など | ||||
| ``` | ||||
|  | ||||
| #### Phase 2: 本番環境 | ||||
| ```bash | ||||
| # 問題なければ本番環境にデプロイ | ||||
| # ailog本体に統合 | ||||
| ``` | ||||
|  | ||||
| ### 8. トラブルシューティング | ||||
|  | ||||
| #### よくある問題 | ||||
| 1. **OAuth認証エラー**: リダイレクトURL設定確認 | ||||
| 2. **PDS接続エラー**: ネットワーク・DNS設定確認   | ||||
| 3. **アバター表示エラー**: CORS設定確認 | ||||
| 4. **CSS競合**: oauth-プレフィックス確認 | ||||
|  | ||||
| #### ログ確認方法 | ||||
| ```bash | ||||
| # 本番環境でエラーが発生した場合 | ||||
| # ブラウザのDevToolsでエラー確認 | ||||
| # logger.jsは本番では無効化されている | ||||
| ``` | ||||
|  | ||||
| ### 9. 本番用設定ファイル | ||||
|  | ||||
| ```bash | ||||
| # ~/.config/syui/ai/log/config.json | ||||
| { | ||||
|   "oauth": { | ||||
|     "environment": "production", | ||||
|     "debug": false, | ||||
|     "test_mode": false | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 10. 推奨デプロイ手順 | ||||
|  | ||||
| ```bash | ||||
| # 1. テスト機能削除 | ||||
| git checkout -b production-ready | ||||
| # App.jsx からTestUI関連を削除 | ||||
|  | ||||
| # 2. 本番ビルド | ||||
| npm run build | ||||
|  | ||||
| # 3. ファイル確認 | ||||
| ls -la dist/ | ||||
|  | ||||
| # 4. ailogサイトに統合 | ||||
| cp dist/assets/* ../my-blog/static/assets/ | ||||
| cp dist/index.html ../my-blog/templates/oauth-assets.html | ||||
|  | ||||
| # 5. ailogサイトでテスト | ||||
| cd ../my-blog | ||||
| hugo server | ||||
|  | ||||
| # 6. 問題なければcommit | ||||
| git add . | ||||
| git commit -m "Production build: Remove test UI, optimize for deployment" | ||||
| ``` | ||||
|  | ||||
| ## 注意事項 | ||||
|  | ||||
| - TestUIは開発・デモ用のため本番では削除必須 | ||||
| - loggerは自動で本番では無効化される | ||||
| - OAuth設定は本番PDS用に調整必要 | ||||
| - バンドルサイズが大きいため今後最適化検討 | ||||
| @@ -1,334 +0,0 @@ | ||||
| # 開発ガイド | ||||
|  | ||||
| ## 設計思想 | ||||
|  | ||||
| このプロジェクトは以下の原則に基づいて設計されています: | ||||
|  | ||||
| ### 1. 環境変数による設定の外部化 | ||||
| - ハードコードを避け、設定は全て環境変数で管理 | ||||
| - `src/config/env.js` で一元管理 | ||||
|  | ||||
| ### 2. PDS(Personal Data Server)の自動判定 | ||||
| - `VITE_HANDLE_LIST` と `VITE_PDS` による自動判定 | ||||
| - syu.is系とbsky.social系の自動振り分け | ||||
|  | ||||
| ### 3. コンポーネントの責任分離 | ||||
| - Hooks: ビジネスロジック | ||||
| - Components: UI表示のみ | ||||
| - Services: 外部API連携 | ||||
| - Utils: 純粋関数 | ||||
|  | ||||
| ## アーキテクチャ詳細 | ||||
|  | ||||
| ### データフロー | ||||
|  | ||||
| ``` | ||||
| User Input | ||||
|     ↓ | ||||
| Hooks (useAuth, useAdminData, usePageContext) | ||||
|     ↓ | ||||
| Services (OAuthService) | ||||
|     ↓ | ||||
| API (atproto.js) | ||||
|     ↓ | ||||
| ATProto Network | ||||
|     ↓ | ||||
| Components (UI Display) | ||||
| ``` | ||||
|  | ||||
| ### 状態管理 | ||||
|  | ||||
| React Hooksによる状態管理: | ||||
| - `useAuth`: OAuth認証状態 | ||||
| - `useAdminData`: 管理者データ(プロフィール、レコード) | ||||
| - `usePageContext`: ページ判定(トップ/個別) | ||||
|  | ||||
| ### OAuth認証フロー | ||||
|  | ||||
| ``` | ||||
| 1. ユーザーがハンドル入力 | ||||
| 2. PDS判定 (syu.is vs bsky.social) | ||||
| 3. 適切なOAuthClientを選択 | ||||
| 4. 標準OAuth画面にリダイレクト | ||||
| 5. 認証完了後コールバック処理 | ||||
| 6. セッション復元・保存 | ||||
| ``` | ||||
|  | ||||
| ## 重要な実装詳細 | ||||
|  | ||||
| ### セッション管理 | ||||
|  | ||||
| `@atproto/oauth-client-browser`が自動的に以下を処理: | ||||
| - IndexedDBへのセッション保存 | ||||
| - トークンの自動更新 | ||||
| - DPoP(Demonstration of Proof of Possession) | ||||
|  | ||||
| **注意**: 手動でのセッション管理は複雑なため、公式ライブラリを使用すること。 | ||||
|  | ||||
| ### PDS判定アルゴリズム | ||||
|  | ||||
| ```javascript | ||||
| // src/utils/pds.js | ||||
| function isSyuIsHandle(handle) { | ||||
|   return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`) | ||||
| } | ||||
| ``` | ||||
|  | ||||
| 1. `VITE_HANDLE_LIST` に含まれるハンドル → syu.is | ||||
| 2. `.syu.is` で終わるハンドル → syu.is   | ||||
| 3. その他 → bsky.social | ||||
|  | ||||
| ### レコードフィルタリング | ||||
|  | ||||
| ```javascript | ||||
| // src/components/RecordTabs.jsx | ||||
| const filterRecords = (records) => { | ||||
|   if (pageContext.isTopPage) { | ||||
|     return records.slice(0, 3) // 最新3件 | ||||
|   } else { | ||||
|     // URL のrkey と record.value.post.url のrkey を照合 | ||||
|     return records.filter(record => { | ||||
|       const recordRkey = new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') | ||||
|       return recordRkey === pageContext.rkey | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## 開発時の注意点 | ||||
|  | ||||
| ### 1. 環境変数の命名 | ||||
|  | ||||
| - `VITE_` プレフィックス必須(Viteの制約) | ||||
| - JSON形式の環境変数は文字列として定義 | ||||
|  | ||||
| ```bash | ||||
| # ❌ 間違い | ||||
| VITE_HANDLE_LIST=["ai.syui.ai"] | ||||
|  | ||||
| # ✅ 正しい   | ||||
| VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai"] | ||||
| ``` | ||||
|  | ||||
| ### 2. API エラーハンドリング | ||||
|  | ||||
| ```javascript | ||||
| // src/api/atproto.js | ||||
| async function request(url) { | ||||
|   const response = await fetch(url) | ||||
|   if (!response.ok) { | ||||
|     throw new Error(`HTTP ${response.status}`) | ||||
|   } | ||||
|   return await response.json() | ||||
| } | ||||
| ``` | ||||
|  | ||||
| すべてのAPI呼び出しでエラーハンドリングを実装。 | ||||
|  | ||||
| ### 3. コンポーネント設計 | ||||
|  | ||||
| ```javascript | ||||
| // ❌ Bad: ビジネスロジックがコンポーネント内 | ||||
| function MyComponent() { | ||||
|   const [data, setData] = useState([]) | ||||
|   useEffect(() => { | ||||
|     fetch('/api/data').then(setData) | ||||
|   }, []) | ||||
|   return <div>{data.map(...)}</div> | ||||
| } | ||||
|  | ||||
| // ✅ Good: Hooksでロジック分離 | ||||
| function MyComponent() { | ||||
|   const { data, loading, error } = useMyData() | ||||
|   if (loading) return <Loading /> | ||||
|   if (error) return <Error /> | ||||
|   return <div>{data.map(...)}</div> | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## デバッグ手法 | ||||
|  | ||||
| ### 1. OAuth デバッグ | ||||
|  | ||||
| ```javascript | ||||
| // ブラウザの開発者ツールで確認 | ||||
| localStorage.clear()          // セッションクリア | ||||
| sessionStorage.clear()        // 一時データクリア | ||||
|  | ||||
| // IndexedDB確認(Application タブ) | ||||
| // ATProtoの認証データが保存される | ||||
| ``` | ||||
|  | ||||
| ### 2. PDS判定デバッグ | ||||
|  | ||||
| ```javascript | ||||
| // src/utils/pds.js にログ追加 | ||||
| console.log('Handle:', handle) | ||||
| console.log('Is syu.is:', isSyuIsHandle(handle)) | ||||
| console.log('API Config:', getApiConfig(pds)) | ||||
| ``` | ||||
|  | ||||
| ### 3. レコードフィルタリングデバッグ | ||||
|  | ||||
| ```javascript | ||||
| // src/components/RecordTabs.jsx | ||||
| console.log('Page Context:', pageContext) | ||||
| console.log('All Records:', records.length) | ||||
| console.log('Filtered Records:', filteredRecords.length) | ||||
| ``` | ||||
|  | ||||
| ## パフォーマンス最適化 | ||||
|  | ||||
| ### 1. 並列データ取得 | ||||
|  | ||||
| ```javascript | ||||
| // src/hooks/useAdminData.js | ||||
| const [records, lang, comment] = await Promise.all([ | ||||
|   collections.getBase(apiConfig.pds, did, env.collection), | ||||
|   collections.getLang(apiConfig.pds, did, env.collection), | ||||
|   collections.getComment(apiConfig.pds, did, env.collection) | ||||
| ]) | ||||
| ``` | ||||
|  | ||||
| ### 2. 不要な再レンダリング防止 | ||||
|  | ||||
| ```javascript | ||||
| // useMemo でフィルタリング結果をキャッシュ | ||||
| const filteredRecords = useMemo(() =>  | ||||
|   filterRecords(records),  | ||||
|   [records, pageContext] | ||||
| ) | ||||
| ``` | ||||
|  | ||||
| ## テスト戦略 | ||||
|  | ||||
| ### 1. 単体テスト推奨対象 | ||||
|  | ||||
| - `src/utils/pds.js` - PDS判定ロジック | ||||
| - `src/config/env.js` - 環境変数パース | ||||
| - フィルタリング関数 | ||||
|  | ||||
| ### 2. 統合テスト推奨対象 | ||||
|  | ||||
| - OAuth認証フロー | ||||
| - API呼び出し | ||||
| - レコード表示 | ||||
|  | ||||
| ## デプロイメント | ||||
|  | ||||
| ### 1. 必要ファイル | ||||
|  | ||||
| ``` | ||||
| public/ | ||||
| └── client-metadata.json    # OAuth設定ファイル | ||||
|  | ||||
| dist/                       # ビルド出力 | ||||
| ├── index.html | ||||
| └── assets/ | ||||
|     ├── comment-atproto-[hash].js | ||||
|     └── comment-atproto-[hash].css | ||||
| ``` | ||||
|  | ||||
| ### 2. デプロイ手順 | ||||
|  | ||||
| ```bash | ||||
| # 1. 環境変数設定 | ||||
| cp .env.example .env | ||||
| # 2. 本番用設定を記入 | ||||
| # 3. ビルド | ||||
| npm run build | ||||
| # 4. dist/ フォルダをデプロイ | ||||
| ``` | ||||
|  | ||||
| ### 3. 本番環境チェックリスト | ||||
|  | ||||
| - [ ] `.env` ファイルの本番設定 | ||||
| - [ ] `client-metadata.json` の設置 | ||||
| - [ ] HTTPS 必須(OAuth要件) | ||||
| - [ ] CSP(Content Security Policy)設定 | ||||
|  | ||||
| ## よくある問題と解決法 | ||||
|  | ||||
| ### 1. "OAuth initialization failed" | ||||
|  | ||||
| **原因**: client-metadata.json が見つからない、または形式が正しくない | ||||
|  | ||||
| **解決法**:  | ||||
| ```bash | ||||
| # public/client-metadata.json の存在確認 | ||||
| ls -la public/client-metadata.json | ||||
|  | ||||
| # 形式確認(JSON validation) | ||||
| jq . public/client-metadata.json | ||||
| ``` | ||||
|  | ||||
| ### 2. "Failed to load admin data" | ||||
|  | ||||
| **原因**: 管理者アカウントのDID解決に失敗 | ||||
|  | ||||
| **解決法**: | ||||
| ```bash | ||||
| # 手動でDID解決確認 | ||||
| curl "https://syu.is/xrpc/com.atproto.repo.describeRepo?repo=ai.syui.ai" | ||||
| ``` | ||||
|  | ||||
| ### 3. レコードが表示されない | ||||
|  | ||||
| **原因**: コレクション名の不一致、権限不足 | ||||
|  | ||||
| **解決法**: | ||||
| ```bash | ||||
| # コレクション確認 | ||||
| curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collection=ai.syui.log.chat.lang" | ||||
| ``` | ||||
|  | ||||
| ## 機能拡張ガイド | ||||
|  | ||||
| ### 1. 新しいコレクション追加 | ||||
|  | ||||
| ```javascript | ||||
| // src/api/atproto.js に追加 | ||||
| export const collections = { | ||||
|   // 既存... | ||||
|   async getNewCollection(pds, repo, collection, limit = 10) { | ||||
|     return await atproto.getRecords(pds, repo, `${collection}.new`, limit) | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 2. 新しいPDS対応 | ||||
|  | ||||
| ```javascript | ||||
| // src/utils/pds.js を拡張 | ||||
| export function getApiConfig(pds) { | ||||
|   if (pds.includes('syu.is')) { | ||||
|     // 既存の syu.is 設定 | ||||
|   } else if (pds.includes('newpds.com')) { | ||||
|     return { | ||||
|       pds: `https://newpds.com`, | ||||
|       bsky: `https://bsky.newpds.com`, | ||||
|       plc: `https://plc.newpds.com`, | ||||
|       web: `https://web.newpds.com` | ||||
|     } | ||||
|   } | ||||
|   // デフォルト設定 | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 3. リアルタイム更新追加 | ||||
|  | ||||
| ```javascript | ||||
| // src/hooks/useRealtimeUpdates.js | ||||
| export function useRealtimeUpdates(collection) { | ||||
|   useEffect(() => { | ||||
|     const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe') | ||||
|     ws.onmessage = (event) => { | ||||
|       const data = JSON.parse(event.data) | ||||
|       if (data.collection === collection) { | ||||
|         // 新しいレコードを追加 | ||||
|       } | ||||
|     } | ||||
|     return () => ws.close() | ||||
|   }, [collection]) | ||||
| } | ||||
| ``` | ||||
| @@ -1,110 +0,0 @@ | ||||
| # 環境変数による機能切り替え | ||||
|  | ||||
| ## 概要 | ||||
|  | ||||
| 開発用機能(TestUI、デバッグログ)をenv変数で簡単に有効/無効化できるようになりました。 | ||||
|  | ||||
| ## 設定ファイル | ||||
|  | ||||
| ### 開発環境用: `.env` | ||||
| ```bash | ||||
| # Development/Debug features | ||||
| VITE_ENABLE_TEST_UI=true | ||||
| VITE_ENABLE_DEBUG=true | ||||
| ``` | ||||
|  | ||||
| ### 本番環境用: `.env.production` | ||||
| ```bash | ||||
| # Production settings - Disable development features   | ||||
| VITE_ENABLE_TEST_UI=false | ||||
| VITE_ENABLE_DEBUG=false | ||||
| ``` | ||||
|  | ||||
| ## 制御される機能 | ||||
|  | ||||
| ### 1. TestUI コンポーネント | ||||
| - **制御**: `VITE_ENABLE_TEST_UI` | ||||
| - **true**: TestボタンとTestUI表示 | ||||
| - **false**: TestUI関連が完全に非表示 | ||||
|  | ||||
| ### 2. デバッグログ | ||||
| - **制御**: `VITE_ENABLE_DEBUG` | ||||
| - **true**: console.log等が有効 | ||||
| - **false**: すべてのlogが無効化 | ||||
|  | ||||
| ## 使い方 | ||||
|  | ||||
| ### 開発時 | ||||
| ```bash | ||||
| # .envで有効化されているので通常通り | ||||
| npm run dev | ||||
| npm run build | ||||
| ``` | ||||
|  | ||||
| ### 本番デプロイ時 | ||||
| ```bash | ||||
| # 自動的に .env.production が読み込まれる | ||||
| npm run build | ||||
|  | ||||
| # または明示的に指定 | ||||
| NODE_ENV=production npm run build | ||||
| ``` | ||||
|  | ||||
| ### 手動切り替え | ||||
| ```bash | ||||
| # 一時的にTestUIだけ無効化 | ||||
| VITE_ENABLE_TEST_UI=false npm run dev | ||||
|  | ||||
| # 一時的にデバッグだけ無効化 | ||||
| VITE_ENABLE_DEBUG=false npm run dev | ||||
| ``` | ||||
|  | ||||
| ## 実装詳細 | ||||
|  | ||||
| ### App.jsx | ||||
| ```jsx | ||||
| // Environment-based feature flags | ||||
| const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true' | ||||
| const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true' | ||||
|  | ||||
| // TestUI表示制御 | ||||
| {ENABLE_TEST_UI && showTestUI && ( | ||||
|   <div className="test-section"> | ||||
|     <TestUI /> | ||||
|   </div> | ||||
| )} | ||||
|  | ||||
| // Testボタン表示制御 | ||||
| {ENABLE_TEST_UI && ( | ||||
|   <div className="bottom-actions"> | ||||
|     <button onClick={() => setShowTestUI(!showTestUI)}> | ||||
|       {showTestUI ? 'close test' : 'test'} | ||||
|     </button> | ||||
|   </div> | ||||
| )} | ||||
| ``` | ||||
|  | ||||
| ### logger.js | ||||
| ```jsx | ||||
| class Logger { | ||||
|   constructor() { | ||||
|     this.isDev = import.meta.env.DEV || false | ||||
|     this.debugEnabled = import.meta.env.VITE_ENABLE_DEBUG === 'true' | ||||
|     this.isEnabled = this.isDev && this.debugEnabled | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## メリット | ||||
|  | ||||
| ✅ **コード削除不要**: 機能を残したまま本番で無効化 | ||||
| ✅ **簡単切り替え**: env変数だけで制御 | ||||
| ✅ **自動化対応**: CI/CDで環境別自動ビルド可能 | ||||
| ✅ **デバッグ容易**: 必要時に即座に有効化可能 | ||||
|  | ||||
| ## 本番デプロイチェックリスト | ||||
|  | ||||
| - [ ] `.env.production`でTestUI無効化確認 | ||||
| - [ ] `.env.production`でデバッグ無効化確認   | ||||
| - [ ] 本番ビルドでTestボタン非表示確認 | ||||
| - [ ] 本番でconsole.log出力なし確認 | ||||
| @@ -1,444 +0,0 @@ | ||||
| # OAuth_new 実装ガイド | ||||
|  | ||||
| ## Claude Code用実装指示 | ||||
|  | ||||
| ### 即座に実装可能な改善(優先度:最高) | ||||
|  | ||||
| #### 1. エラーハンドリング強化 | ||||
|  | ||||
| **ファイル**: `src/utils/errorHandler.js` (新規作成) | ||||
| ```javascript | ||||
| 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.status === 400) { | ||||
|     return 'アカウントまたはコレクションが見つかりません' | ||||
|   } else if (error.status === 429) { | ||||
|     return 'レート制限です。しばらく待ってから再試行してください' | ||||
|   } else if (error.status === 500) { | ||||
|     return 'サーバーエラーが発生しました' | ||||
|   } else if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) { | ||||
|     return 'ネットワーク接続を確認してください' | ||||
|   } else if (error.message.includes('timeout')) { | ||||
|     return 'タイムアウトしました。再試行してください' | ||||
|   } | ||||
|   return '予期しないエラーが発生しました' | ||||
| } | ||||
|  | ||||
| export function logError(error, context) { | ||||
|   console.error(`[ATProto Error] ${context}:`, { | ||||
|     message: error.message, | ||||
|     status: error.status, | ||||
|     timestamp: new Date().toISOString() | ||||
|   }) | ||||
| } | ||||
| ``` | ||||
|  | ||||
| **修正**: `src/api/atproto.js` | ||||
| ```javascript | ||||
| import { ATProtoError, logError } from '../utils/errorHandler.js' | ||||
|  | ||||
| async function request(url, options = {}) { | ||||
|   try { | ||||
|     const response = await fetch(url, options) | ||||
|     if (!response.ok) { | ||||
|       throw new ATProtoError( | ||||
|         `HTTP ${response.status}: ${response.statusText}`, | ||||
|         response.status, | ||||
|         { url, options } | ||||
|       ) | ||||
|     } | ||||
|     return await response.json() | ||||
|   } catch (error) { | ||||
|     if (error instanceof ATProtoError) { | ||||
|       logError(error, 'API Request') | ||||
|       throw error | ||||
|     } | ||||
|      | ||||
|     // Network errors | ||||
|     const atprotoError = new ATProtoError( | ||||
|       'ネットワークエラーが発生しました', | ||||
|       0, | ||||
|       { url, originalError: error.message } | ||||
|     ) | ||||
|     logError(atprotoError, 'Network Error') | ||||
|     throw atprotoError | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| **修正**: `src/hooks/useAdminData.js` | ||||
| ```javascript | ||||
| import { getErrorMessage, logError } from '../utils/errorHandler.js' | ||||
|  | ||||
| // loadAdminData関数内のcatchブロック | ||||
| } catch (err) { | ||||
|   logError(err, 'useAdminData.loadAdminData') | ||||
|   setError(getErrorMessage(err)) | ||||
| } finally { | ||||
|   setLoading(false) | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 2. シンプルなキャッシュシステム | ||||
|  | ||||
| **ファイル**: `src/utils/cache.js` (新規作成) | ||||
| ```javascript | ||||
| class SimpleCache { | ||||
|   constructor(ttl = 30000) { // 30秒TTL | ||||
|     this.cache = new Map() | ||||
|     this.ttl = ttl | ||||
|   } | ||||
|  | ||||
|   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 | ||||
|     } | ||||
|     return item.data | ||||
|   } | ||||
|  | ||||
|   set(key, data) { | ||||
|     this.cache.set(key, { | ||||
|       data, | ||||
|       timestamp: Date.now() | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   clear() { | ||||
|     this.cache.clear() | ||||
|   } | ||||
|  | ||||
|   invalidatePattern(pattern) { | ||||
|     for (const key of this.cache.keys()) { | ||||
|       if (key.includes(pattern)) { | ||||
|         this.cache.delete(key) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const dataCache = new SimpleCache() | ||||
| ``` | ||||
|  | ||||
| **修正**: `src/api/atproto.js` | ||||
| ```javascript | ||||
| import { dataCache } from '../utils/cache.js' | ||||
|  | ||||
| export const collections = { | ||||
|   async getBase(pds, repo, collection, limit = 10) { | ||||
|     const cacheKey = `base:${pds}:${repo}:${collection}` | ||||
|     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) { | ||||
|     const cacheKey = `lang:${pds}:${repo}:${collection}` | ||||
|     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) { | ||||
|     const cacheKey = `comment:${pds}:${repo}:${collection}` | ||||
|     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 | ||||
|   }, | ||||
|  | ||||
|   // 投稿後にキャッシュをクリア | ||||
|   invalidateCache(collection) { | ||||
|     dataCache.invalidatePattern(collection) | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 3. ローディングスケルトン | ||||
|  | ||||
| **ファイル**: `src/components/LoadingSkeleton.jsx` (新規作成) | ||||
| ```javascript | ||||
| import React from 'react' | ||||
|  | ||||
| export default function LoadingSkeleton({ count = 3 }) { | ||||
|   return ( | ||||
|     <div className="loading-skeleton"> | ||||
|       {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"></div> | ||||
|             <div className="skeleton-line short"></div> | ||||
|             <div className="skeleton-line shorter"></div> | ||||
|           </div> | ||||
|         </div> | ||||
|       ))} | ||||
|        | ||||
|       <style jsx>{` | ||||
|         .loading-skeleton { | ||||
|           padding: 10px; | ||||
|         } | ||||
|         .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: loading 1.5s infinite; | ||||
|           margin-right: 10px; | ||||
|           flex-shrink: 0; | ||||
|         } | ||||
|         .skeleton-content { | ||||
|           flex: 1; | ||||
|         } | ||||
|         .skeleton-line { | ||||
|           height: 12px; | ||||
|           background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); | ||||
|           background-size: 200% 100%; | ||||
|           animation: loading 1.5s infinite; | ||||
|           margin-bottom: 8px; | ||||
|           border-radius: 4px; | ||||
|         } | ||||
|         .skeleton-line.short { | ||||
|           width: 70%; | ||||
|         } | ||||
|         .skeleton-line.shorter { | ||||
|           width: 40%; | ||||
|         } | ||||
|         @keyframes loading { | ||||
|           0% { background-position: 200% 0; } | ||||
|           100% { background-position: -200% 0; } | ||||
|         } | ||||
|       `}</style> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| ``` | ||||
|  | ||||
| **修正**: `src/components/RecordTabs.jsx` | ||||
| ```javascript | ||||
| import LoadingSkeleton from './LoadingSkeleton.jsx' | ||||
|  | ||||
| // RecordTabsコンポーネント内 | ||||
| {activeTab === 'lang' && ( | ||||
|   loading ? ( | ||||
|     <LoadingSkeleton count={3} /> | ||||
|   ) : ( | ||||
|     <RecordList  | ||||
|       title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"} | ||||
|       records={filteredLangRecords}  | ||||
|       apiConfig={apiConfig}  | ||||
|     /> | ||||
|   ) | ||||
| )} | ||||
| ``` | ||||
|  | ||||
| ### 中期実装(1週間以内) | ||||
|  | ||||
| #### 4. リトライ機能 | ||||
|  | ||||
| **修正**: `src/api/atproto.js` | ||||
| ```javascript | ||||
| async function requestWithRetry(url, options = {}, maxRetries = 3) { | ||||
|   for (let i = 0; i < maxRetries; i++) { | ||||
|     try { | ||||
|       return await request(url, options) | ||||
|     } catch (error) { | ||||
|       if (i === maxRetries - 1) throw error | ||||
|        | ||||
|       // 429 (レート制限) の場合は長めに待機 | ||||
|       const baseDelay = error.status === 429 ? 5000 : 1000 | ||||
|       const delay = Math.min(baseDelay * Math.pow(2, i), 30000) | ||||
|        | ||||
|       console.log(`Retry ${i + 1}/${maxRetries} after ${delay}ms`) | ||||
|       await new Promise(resolve => setTimeout(resolve, delay)) | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| // 全てのAPI呼び出しでrequestをrequestWithRetryに変更 | ||||
| export const atproto = { | ||||
|   async getDid(pds, handle) { | ||||
|     const res = await requestWithRetry(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`) | ||||
|     return res.did | ||||
|   }, | ||||
|   // ...他のメソッドも同様に変更 | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 5. 段階的ローディング | ||||
|  | ||||
| **修正**: `src/hooks/useAdminData.js` | ||||
| ```javascript | ||||
| export function useAdminData() { | ||||
|   const [adminData, setAdminData] = useState({  | ||||
|     did: '',  | ||||
|     profile: null,  | ||||
|     records: [],  | ||||
|     apiConfig: null  | ||||
|   }) | ||||
|   const [langRecords, setLangRecords] = useState([]) | ||||
|   const [commentRecords, setCommentRecords] = useState([]) | ||||
|   const [loadingStates, setLoadingStates] = useState({ | ||||
|     admin: true, | ||||
|     base: true, | ||||
|     lang: true, | ||||
|     comment: true | ||||
|   }) | ||||
|   const [error, setError] = useState(null) | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadAdminData() | ||||
|   }, []) | ||||
|  | ||||
|   const loadAdminData = async () => { | ||||
|     try { | ||||
|       setError(null) | ||||
|        | ||||
|       // Phase 1: 管理者情報を最初に取得 | ||||
|       setLoadingStates(prev => ({ ...prev, admin: true })) | ||||
|       const apiConfig = getApiConfig(`https://${env.pds}`) | ||||
|       const did = await atproto.getDid(env.pds, env.admin) | ||||
|       const profile = await atproto.getProfile(apiConfig.bsky, did) | ||||
|        | ||||
|       setAdminData({ did, profile, records: [], apiConfig }) | ||||
|       setLoadingStates(prev => ({ ...prev, admin: false })) | ||||
|  | ||||
|       // Phase 2: 基本レコードを取得 | ||||
|       setLoadingStates(prev => ({ ...prev, base: true })) | ||||
|       const records = await collections.getBase(apiConfig.pds, did, env.collection) | ||||
|       setAdminData(prev => ({ ...prev, records })) | ||||
|       setLoadingStates(prev => ({ ...prev, base: false })) | ||||
|  | ||||
|       // Phase 3: lang/commentを並列取得 | ||||
|       const langPromise = collections.getLang(apiConfig.pds, did, env.collection) | ||||
|         .then(data => { | ||||
|           setLangRecords(data) | ||||
|           setLoadingStates(prev => ({ ...prev, lang: false })) | ||||
|         }) | ||||
|         .catch(err => { | ||||
|           console.warn('Failed to load lang records:', err) | ||||
|           setLoadingStates(prev => ({ ...prev, lang: false })) | ||||
|         }) | ||||
|  | ||||
|       const commentPromise = collections.getComment(apiConfig.pds, did, env.collection) | ||||
|         .then(data => { | ||||
|           setCommentRecords(data) | ||||
|           setLoadingStates(prev => ({ ...prev, comment: false })) | ||||
|         }) | ||||
|         .catch(err => { | ||||
|           console.warn('Failed to load comment records:', err) | ||||
|           setLoadingStates(prev => ({ ...prev, comment: false })) | ||||
|         }) | ||||
|  | ||||
|       await Promise.all([langPromise, commentPromise]) | ||||
|  | ||||
|     } catch (err) { | ||||
|       logError(err, 'useAdminData.loadAdminData') | ||||
|       setError(getErrorMessage(err)) | ||||
|       // エラー時もローディング状態を解除 | ||||
|       setLoadingStates({ | ||||
|         admin: false, | ||||
|         base: false, | ||||
|         lang: false, | ||||
|         comment: false | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     adminData, | ||||
|     langRecords, | ||||
|     commentRecords, | ||||
|     loading: Object.values(loadingStates).some(Boolean), | ||||
|     loadingStates, | ||||
|     error, | ||||
|     refresh: loadAdminData | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 緊急時対応 | ||||
|  | ||||
| #### フォールバック機能 | ||||
|  | ||||
| **修正**: `src/hooks/useAdminData.js` | ||||
| ```javascript | ||||
| // エラー時でも基本機能を維持 | ||||
| const loadWithFallback = async () => { | ||||
|   try { | ||||
|     await loadAdminData() | ||||
|   } catch (err) { | ||||
|     // フォールバック:最低限の表示を維持 | ||||
|     setAdminData({ | ||||
|       did: env.admin, // ハンドルをDIDとして使用 | ||||
|       profile: { | ||||
|         handle: env.admin, | ||||
|         displayName: env.admin, | ||||
|         avatar: null | ||||
|       }, | ||||
|       records: [], | ||||
|       apiConfig: getApiConfig(`https://${env.pds}`) | ||||
|     }) | ||||
|     setError('一部機能が利用できません。基本表示で継続します。') | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## 実装チェックリスト | ||||
|  | ||||
| ### Phase 1 (即座実装) | ||||
| - [ ] `src/utils/errorHandler.js` 作成 | ||||
| - [ ] `src/utils/cache.js` 作成   | ||||
| - [ ] `src/components/LoadingSkeleton.jsx` 作成 | ||||
| - [ ] `src/api/atproto.js` エラーハンドリング追加 | ||||
| - [ ] `src/hooks/useAdminData.js` エラーハンドリング改善 | ||||
| - [ ] `src/components/RecordTabs.jsx` ローディング表示追加 | ||||
|  | ||||
| ### Phase 2 (1週間以内) | ||||
| - [ ] `src/api/atproto.js` リトライ機能追加 | ||||
| - [ ] `src/hooks/useAdminData.js` 段階的ローディング実装 | ||||
| - [ ] キャッシュクリア機能の投稿フォーム統合 | ||||
|  | ||||
| ### テスト項目 | ||||
| - [ ] エラー状態でも最低限表示される | ||||
| - [ ] キャッシュが適切に動作する | ||||
| - [ ] ローディング表示が適切に出る | ||||
| - [ ] リトライが正常に動作する | ||||
|  | ||||
| ## パフォーマンス目標 | ||||
|  | ||||
| - **初期表示**: 3秒 → 1秒 | ||||
| - **キャッシュヒット率**: 70%以上 | ||||
| - **エラー率**: 10% → 2%以下 | ||||
| - **ユーザー体験**: ローディング状態が常に可視化 | ||||
|  | ||||
| この実装により、./oauthで発生している「同じ問題の繰り返し」を避け、 | ||||
| 安定した成長可能なシステムが構築できます。 | ||||
| @@ -1,448 +0,0 @@ | ||||
| # OAuth_new 改善計画 | ||||
|  | ||||
| ## 現状分析 | ||||
|  | ||||
| ### 良い点 | ||||
| - ✅ クリーンなアーキテクチャ(Hooks分離) | ||||
| - ✅ 公式ライブラリ使用(@atproto/oauth-client-browser) | ||||
| - ✅ 適切なエラーハンドリング | ||||
| - ✅ 包括的なドキュメント | ||||
| - ✅ 環境変数による設定外部化 | ||||
|  | ||||
| ### 問題点 | ||||
| - ❌ パフォーマンス:毎回全データを並列取得 | ||||
| - ❌ UX:ローディング状態が分かりにくい | ||||
| - ❌ スケーラビリティ:データ量増加への対応不足 | ||||
| - ❌ エラー詳細度:汎用的すぎるエラーメッセージ | ||||
| - ❌ リアルタイム性:手動更新が必要 | ||||
|  | ||||
| ## 改善計画 | ||||
|  | ||||
| ### Phase 1: 安定性・パフォーマンス向上(優先度:高) | ||||
|  | ||||
| #### 1.1 キャッシュシステム導入 | ||||
| ```javascript | ||||
| // 新規ファイル: src/utils/cache.js | ||||
| export class DataCache { | ||||
|   constructor(ttl = 30000) { // 30秒TTL | ||||
|     this.cache = new Map() | ||||
|     this.ttl = ttl | ||||
|   } | ||||
|  | ||||
|   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 | ||||
|     } | ||||
|     return item.data | ||||
|   } | ||||
|  | ||||
|   set(key, data) { | ||||
|     this.cache.set(key, { | ||||
|       data, | ||||
|       timestamp: Date.now() | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   invalidate(pattern) { | ||||
|     for (const key of this.cache.keys()) { | ||||
|       if (key.includes(pattern)) { | ||||
|         this.cache.delete(key) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 1.2 リトライ機能付きAPI | ||||
| ```javascript | ||||
| // 修正: src/api/atproto.js | ||||
| async function requestWithRetry(url, options = {}, maxRetries = 3) { | ||||
|   for (let i = 0; i < maxRetries; i++) { | ||||
|     try { | ||||
|       const response = await fetch(url, options) | ||||
|       if (!response.ok) { | ||||
|         throw new Error(`HTTP ${response.status}`) | ||||
|       } | ||||
|       return await response.json() | ||||
|     } catch (error) { | ||||
|       if (i === maxRetries - 1) throw error | ||||
|        | ||||
|       // 指数バックオフ | ||||
|       const delay = Math.min(1000 * Math.pow(2, i), 10000) | ||||
|       await new Promise(resolve => setTimeout(resolve, delay)) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 1.3 詳細なエラーハンドリング | ||||
| ```javascript | ||||
| // 新規ファイル: src/utils/errorHandler.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.status === 400) { | ||||
|     return 'アカウントまたはコレクションが見つかりません' | ||||
|   } else if (error.status === 429) { | ||||
|     return 'レート制限です。しばらく待ってから再試行してください' | ||||
|   } else if (error.status === 500) { | ||||
|     return 'サーバーエラーが発生しました' | ||||
|   } else if (error.message.includes('NetworkError')) { | ||||
|     return 'ネットワーク接続を確認してください' | ||||
|   } | ||||
|   return '予期しないエラーが発生しました' | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Phase 2: UX改善(優先度:中) | ||||
|  | ||||
| #### 2.1 ローディング状態の改善 | ||||
| ```javascript | ||||
| // 修正: src/components/RecordTabs.jsx | ||||
| const LoadingSkeleton = ({ count = 3 }) => ( | ||||
|   <div className="loading-skeleton"> | ||||
|     {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"></div> | ||||
|           <div className="skeleton-line short"></div> | ||||
|         </div> | ||||
|       </div> | ||||
|     ))} | ||||
|   </div> | ||||
| ) | ||||
|  | ||||
| // CSS追加 | ||||
| .skeleton-item { | ||||
|   display: flex; | ||||
|   padding: 10px; | ||||
|   border: 1px solid #eee; | ||||
|   margin: 5px 0; | ||||
| } | ||||
| .skeleton-avatar { | ||||
|   width: 32px; | ||||
|   height: 32px; | ||||
|   border-radius: 50%; | ||||
|   background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); | ||||
|   background-size: 200% 100%; | ||||
|   animation: loading 1.5s infinite; | ||||
| } | ||||
| @keyframes loading { | ||||
|   0% { background-position: 200% 0; } | ||||
|   100% { background-position: -200% 0; } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 2.2 インクリメンタルローディング | ||||
| ```javascript | ||||
| // 修正: src/hooks/useAdminData.js | ||||
| export function useAdminData() { | ||||
|   const [adminData, setAdminData] = useState({  | ||||
|     did: '',  | ||||
|     profile: null,  | ||||
|     records: [],  | ||||
|     apiConfig: null  | ||||
|   }) | ||||
|   const [langRecords, setLangRecords] = useState([]) | ||||
|   const [commentRecords, setCommentRecords] = useState([]) | ||||
|   const [loadingStates, setLoadingStates] = useState({ | ||||
|     admin: true, | ||||
|     lang: true, | ||||
|     comment: true | ||||
|   }) | ||||
|  | ||||
|   const loadAdminData = async () => { | ||||
|     try { | ||||
|       // 管理者データを最初に読み込み | ||||
|       setLoadingStates(prev => ({ ...prev, admin: true })) | ||||
|       const apiConfig = getApiConfig(`https://${env.pds}`) | ||||
|       const did = await atproto.getDid(env.pds, env.admin) | ||||
|       const profile = await atproto.getProfile(apiConfig.bsky, did) | ||||
|        | ||||
|       setAdminData({ did, profile, records: [], apiConfig }) | ||||
|       setLoadingStates(prev => ({ ...prev, admin: false })) | ||||
|  | ||||
|       // 基本レコードを読み込み | ||||
|       const records = await collections.getBase(apiConfig.pds, did, env.collection) | ||||
|       setAdminData(prev => ({ ...prev, records })) | ||||
|  | ||||
|       // lang/commentを並列で読み込み | ||||
|       const [lang, comment] = await Promise.all([ | ||||
|         collections.getLang(apiConfig.pds, did, env.collection) | ||||
|           .finally(() => setLoadingStates(prev => ({ ...prev, lang: false }))), | ||||
|         collections.getComment(apiConfig.pds, did, env.collection) | ||||
|           .finally(() => setLoadingStates(prev => ({ ...prev, comment: false }))) | ||||
|       ]) | ||||
|  | ||||
|       setLangRecords(lang) | ||||
|       setCommentRecords(comment) | ||||
|     } catch (err) { | ||||
|       // エラーハンドリング | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     adminData, | ||||
|     langRecords, | ||||
|     commentRecords, | ||||
|     loadingStates, | ||||
|     refresh: loadAdminData | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Phase 3: リアルタイム機能(優先度:中) | ||||
|  | ||||
| #### 3.1 WebSocket統合 | ||||
| ```javascript | ||||
| // 新規ファイル: src/hooks/useRealtimeUpdates.js | ||||
| import { useState, useEffect, useRef } from 'react' | ||||
|  | ||||
| export function useRealtimeUpdates(collection, onNewRecord) { | ||||
|   const [connected, setConnected] = useState(false) | ||||
|   const wsRef = useRef(null) | ||||
|   const reconnectTimeoutRef = useRef(null) | ||||
|  | ||||
|   const connect = () => { | ||||
|     try { | ||||
|       wsRef.current = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe') | ||||
|        | ||||
|       wsRef.current.onopen = () => { | ||||
|         setConnected(true) | ||||
|         console.log('WebSocket connected') | ||||
|          | ||||
|         // Subscribe to specific collection | ||||
|         wsRef.current.send(JSON.stringify({ | ||||
|           type: 'subscribe', | ||||
|           collections: [collection] | ||||
|         })) | ||||
|       } | ||||
|  | ||||
|       wsRef.current.onmessage = (event) => { | ||||
|         try { | ||||
|           const data = JSON.parse(event.data) | ||||
|           if (data.collection === collection && data.commit?.operation === 'create') { | ||||
|             onNewRecord(data.commit.record) | ||||
|           } | ||||
|         } catch (err) { | ||||
|           console.warn('Failed to parse WebSocket message:', err) | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       wsRef.current.onclose = () => { | ||||
|         setConnected(false) | ||||
|         // Auto-reconnect after 5 seconds | ||||
|         reconnectTimeoutRef.current = setTimeout(connect, 5000) | ||||
|       } | ||||
|  | ||||
|       wsRef.current.onerror = (error) => { | ||||
|         console.error('WebSocket error:', error) | ||||
|         setConnected(false) | ||||
|       } | ||||
|     } catch (err) { | ||||
|       console.error('Failed to connect WebSocket:', err) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     connect() | ||||
|      | ||||
|     return () => { | ||||
|       if (wsRef.current) { | ||||
|         wsRef.current.close() | ||||
|       } | ||||
|       if (reconnectTimeoutRef.current) { | ||||
|         clearTimeout(reconnectTimeoutRef.current) | ||||
|       } | ||||
|     } | ||||
|   }, [collection]) | ||||
|  | ||||
|   return { connected } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 3.2 オプティミスティック更新 | ||||
| ```javascript | ||||
| // 修正: src/components/CommentForm.jsx | ||||
| const handleSubmit = async (e) => { | ||||
|   e.preventDefault() | ||||
|   if (!text.trim() || !url.trim()) return | ||||
|  | ||||
|   setLoading(true) | ||||
|   setError(null) | ||||
|  | ||||
|   // オプティミスティック更新用の仮レコード | ||||
|   const optimisticRecord = { | ||||
|     uri: `temp-${Date.now()}`, | ||||
|     cid: 'temp', | ||||
|     value: { | ||||
|       $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() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // UIに即座に反映 | ||||
|   if (onOptimisticUpdate) { | ||||
|     onOptimisticUpdate(optimisticRecord) | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const record = { | ||||
|       repo: user.did, | ||||
|       collection: env.collection, | ||||
|       rkey: `comment-${Date.now()}`, | ||||
|       record: optimisticRecord.value | ||||
|     } | ||||
|  | ||||
|     await atproto.putRecord(null, record, agent) | ||||
|  | ||||
|     // 成功時はフォームをクリア | ||||
|     setText('') | ||||
|     setUrl('') | ||||
|      | ||||
|     if (onCommentPosted) { | ||||
|       onCommentPosted() | ||||
|     } | ||||
|   } catch (err) { | ||||
|     // 失敗時はオプティミスティック更新を取り消し | ||||
|     if (onOptimisticRevert) { | ||||
|       onOptimisticRevert(optimisticRecord.uri) | ||||
|     } | ||||
|     setError(err.message) | ||||
|   } finally { | ||||
|     setLoading(false) | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### Phase 4: TypeScript化・テスト(優先度:低) | ||||
|  | ||||
| #### 4.1 TypeScript移行 | ||||
| ```typescript | ||||
| // 新規ファイル: src/types/atproto.ts | ||||
| export interface ATProtoRecord { | ||||
|   uri: string | ||||
|   cid: string | ||||
|   value: { | ||||
|     $type: string | ||||
|     createdAt: string | ||||
|     [key: string]: any | ||||
|   } | ||||
| } | ||||
|  | ||||
| export interface CommentRecord extends ATProtoRecord { | ||||
|   value: { | ||||
|     $type: string | ||||
|     url: string | ||||
|     comments: Comment[] | ||||
|     createdAt: string | ||||
|   } | ||||
| } | ||||
|  | ||||
| export interface Comment { | ||||
|   url: string | ||||
|   text: string | ||||
|   author: Author | ||||
|   createdAt: string | ||||
| } | ||||
|  | ||||
| export interface Author { | ||||
|   did: string | ||||
|   handle: string | ||||
|   displayName?: string | ||||
|   avatar?: string | ||||
| } | ||||
| ``` | ||||
|  | ||||
| #### 4.2 テスト環境 | ||||
| ```javascript | ||||
| // 新規ファイル: src/tests/hooks/useAdminData.test.js | ||||
| import { renderHook, waitFor } from '@testing-library/react' | ||||
| import { useAdminData } from '../../hooks/useAdminData' | ||||
|  | ||||
| // Mock API | ||||
| jest.mock('../../api/atproto', () => ({ | ||||
|   atproto: { | ||||
|     getDid: jest.fn(), | ||||
|     getProfile: jest.fn() | ||||
|   }, | ||||
|   collections: { | ||||
|     getBase: jest.fn(), | ||||
|     getLang: jest.fn(), | ||||
|     getComment: jest.fn() | ||||
|   } | ||||
| })) | ||||
|  | ||||
| describe('useAdminData', () => { | ||||
|   test('loads admin data successfully', async () => { | ||||
|     const { result } = renderHook(() => useAdminData()) | ||||
|      | ||||
|     await waitFor(() => { | ||||
|       expect(result.current.adminData.did).toBeTruthy() | ||||
|     }) | ||||
|   }) | ||||
| }) | ||||
| ``` | ||||
|  | ||||
| ## 実装優先順位 | ||||
|  | ||||
| ### 今すぐ実装すべき(Phase 1) | ||||
| 1. **エラーハンドリング改善** - 1日で実装可能 | ||||
| 2. **キャッシュシステム** - 2日で実装可能 | ||||
| 3. **リトライ機能** - 1日で実装可能 | ||||
|  | ||||
| ### 短期実装(1週間以内) | ||||
| 1. **ローディングスケルトン** - UX大幅改善 | ||||
| 2. **インクリメンタルローディング** - パフォーマンス向上 | ||||
|  | ||||
| ### 中期実装(1ヶ月以内) | ||||
| 1. **WebSocketリアルタイム更新** - 新機能 | ||||
| 2. **オプティミスティック更新** - UX向上 | ||||
|  | ||||
| ### 長期実装(必要に応じて) | ||||
| 1. **TypeScript化** - 保守性向上 | ||||
| 2. **テスト追加** - 品質保証 | ||||
|  | ||||
| ## 注意事項 | ||||
|  | ||||
| ### 既存機能への影響 | ||||
| - すべての改善は後方互換性を保つ | ||||
| - 段階的実装で破綻リスクを最小化 | ||||
| - 各Phase完了後に動作確認 | ||||
|  | ||||
| ### パフォーマンス指標 | ||||
| - 初期表示時間: 現在3秒 → 目標1秒 | ||||
| - キャッシュヒット率: 目標70%以上 | ||||
| - エラー率: 現在10% → 目標2%以下 | ||||
|  | ||||
| ### ユーザビリティ指標   | ||||
| - ローディング状態の可視化 | ||||
| - エラーメッセージの分かりやすさ | ||||
| - リアルタイム更新の応答性 | ||||
|  | ||||
| この改善計画により、oauth_newは./oauthの問題を回避しながら、 | ||||
| より安定した高性能なシステムに進化できます。 | ||||
| @@ -1,81 +0,0 @@ | ||||
| # OAuth認証の修正案 | ||||
|  | ||||
| ## 現在の問題 | ||||
|  | ||||
| 1. **スコープエラー**: `Missing required scope: transition:generic` | ||||
|    - OAuth認証時に必要なスコープが不足している | ||||
|    - ✅ 修正済み: `scope: 'atproto transition:generic'` に変更 | ||||
|  | ||||
| 2. **401エラー**: PDSへの直接アクセス | ||||
|    - `https://shiitake.us-east.host.bsky.network/xrpc/app.bsky.actor.getProfile` で401エラー | ||||
|    - 原因: 個人のPDSに直接アクセスしているが、これは認証が必要 | ||||
|    - 解決策: 公開APIエンドポイント(`https://public.api.bsky.app`)を使用すべき | ||||
|  | ||||
| 3. **セッション保存の問題**: handleが`@unknown`になる | ||||
|    - OAuth認証後にセッションが正しく保存されていない | ||||
|    - ✅ 修正済み: Agentの作成方法を修正 | ||||
|  | ||||
| ## 修正が必要な箇所 | ||||
|  | ||||
| ### 1. avatarFetcher.js の修正 | ||||
| 個人のPDSではなく、公開APIを使用するように修正: | ||||
|  | ||||
| ```javascript | ||||
| // 現在の問題のあるコード | ||||
| const response = await fetch(`${apiConfig.bsky}/xrpc/app.bsky.actor.getProfile?actor=${did}`) | ||||
|  | ||||
| // 修正案 | ||||
| // PDSに関係なく、常に公開APIを使用 | ||||
| const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`) | ||||
| ``` | ||||
|  | ||||
| ### 2. セッション復元の改善 | ||||
| OAuth認証後のコールバック処理で、セッションが正しく復元されていない可能性がある。 | ||||
|  | ||||
| ```javascript | ||||
| // restoreSession メソッドの改善 | ||||
| async restoreSession() { | ||||
|   // Try both clients | ||||
|   for (const [name, client] of Object.entries(this.clients)) { | ||||
|     if (!client) continue | ||||
|      | ||||
|     const result = await client.init() | ||||
|     if (result?.session) { | ||||
|       // セッション処理を確実に行う | ||||
|       this.agent = new Agent(result.session) | ||||
|       const sessionInfo = await this.processSession(result.session) | ||||
|        | ||||
|       // セッション情報をログに出力(デバッグ用) | ||||
|       logger.log('Session restored:', { name, sessionInfo }) | ||||
|        | ||||
|       return sessionInfo | ||||
|     } | ||||
|   } | ||||
|   return null | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## 根本的な問題 | ||||
|  | ||||
| 1. **PDSアクセスの誤解** | ||||
|    - `app.bsky.actor.getProfile` は公開API(認証不要) | ||||
|    - 個人のPDSサーバーに直接アクセスする必要はない | ||||
|    - 常に `https://public.api.bsky.app` を使用すべき | ||||
|  | ||||
| 2. **OAuth Clientの初期化タイミング** | ||||
|    - コールバック時に両方のクライアント(bsky, syu)を試す必要がある | ||||
|    - どちらのPDSでログインしたか分からないため | ||||
|  | ||||
| ## 推奨される修正手順 | ||||
|  | ||||
| 1. **即座の修正**(401エラー解決) | ||||
|    - `avatarFetcher.js` で公開APIを使用 | ||||
|    - `getProfile` 呼び出しをすべて公開APIに変更 | ||||
|  | ||||
| 2. **セッション管理の改善** | ||||
|    - OAuth認証後のセッション復元を確実に | ||||
|    - エラーハンドリングの強化 | ||||
|  | ||||
| 3. **デバッグ情報の追加** | ||||
|    - セッション復元時のログ追加 | ||||
|    - どのOAuthクライアントが使用されたか確認 | ||||
| @@ -1,601 +0,0 @@ | ||||
| # Phase 1: 即座実装可能な修正 | ||||
|  | ||||
| ## 1. エラーハンドリング強化(30分で実装) | ||||
|  | ||||
| ### ファイル作成: `src/utils/errorHandler.js` | ||||
| ```javascript | ||||
| 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 | ||||
|   } | ||||
|    | ||||
|   console.error(`[ATProto Error] ${context}:`, errorInfo) | ||||
|    | ||||
|   // 本番環境では外部ログサービスに送信することも可能 | ||||
|   // if (import.meta.env.PROD) { | ||||
|   //   sendToLogService(errorInfo) | ||||
|   // } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 修正: `src/api/atproto.js` のrequest関数 | ||||
| ```javascript | ||||
| import { ATProtoError, logError } from '../utils/errorHandler.js' | ||||
|  | ||||
| async function request(url, options = {}) { | ||||
|   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 | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 修正: `src/hooks/useAdminData.js` | ||||
| ```javascript | ||||
| import { getErrorMessage, logError } from '../utils/errorHandler.js' | ||||
|  | ||||
| export function useAdminData() { | ||||
|   // 既存のstate... | ||||
|   const [error, setError] = useState(null) | ||||
|   const [retryCount, setRetryCount] = useState(0) | ||||
|  | ||||
|   const loadAdminData = async () => { | ||||
|     try { | ||||
|       setLoading(true) | ||||
|       setError(null) | ||||
|  | ||||
|       const apiConfig = getApiConfig(`https://${env.pds}`) | ||||
|       const did = await atproto.getDid(env.pds, env.admin) | ||||
|       const profile = await atproto.getProfile(apiConfig.bsky, did) | ||||
|        | ||||
|       // Load all data in parallel | ||||
|       const [records, lang, comment] = await Promise.all([ | ||||
|         collections.getBase(apiConfig.pds, did, env.collection), | ||||
|         collections.getLang(apiConfig.pds, did, env.collection), | ||||
|         collections.getComment(apiConfig.pds, did, env.collection) | ||||
|       ]) | ||||
|  | ||||
|       setAdminData({ did, profile, records, apiConfig }) | ||||
|       setLangRecords(lang) | ||||
|       setCommentRecords(comment) | ||||
|       setRetryCount(0) // 成功時はリトライカウントをリセット | ||||
|     } catch (err) { | ||||
|       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) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     adminData, | ||||
|     langRecords, | ||||
|     commentRecords, | ||||
|     loading, | ||||
|     error, | ||||
|     retryCount, | ||||
|     refresh: loadAdminData | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## 2. シンプルキャッシュ(15分で実装) | ||||
|  | ||||
| ### ファイル作成: `src/utils/cache.js` | ||||
| ```javascript | ||||
| 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 | ||||
|     } | ||||
|      | ||||
|     console.log(`Cache hit: ${key}`) | ||||
|     return item.data | ||||
|   } | ||||
|  | ||||
|   set(key, data) { | ||||
|     this.cache.set(key, { | ||||
|       data, | ||||
|       timestamp: Date.now() | ||||
|     }) | ||||
|     console.log(`Cache set: ${key}`) | ||||
|   } | ||||
|  | ||||
|   clear() { | ||||
|     this.cache.clear() | ||||
|     console.log('Cache cleared') | ||||
|   } | ||||
|  | ||||
|   invalidatePattern(pattern) { | ||||
|     let deletedCount = 0 | ||||
|     for (const key of this.cache.keys()) { | ||||
|       if (key.includes(pattern)) { | ||||
|         this.cache.delete(key) | ||||
|         deletedCount++ | ||||
|       } | ||||
|     } | ||||
|     console.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 | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 修正: `src/api/atproto.js` のcollections | ||||
| ```javascript | ||||
| import { dataCache } from '../utils/cache.js' | ||||
|  | ||||
| export const collections = { | ||||
|   async getBase(pds, repo, collection, limit = 10) { | ||||
|     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) { | ||||
|     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) { | ||||
|     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) { | ||||
|     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) { | ||||
|     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) { | ||||
|     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) | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 修正: `src/components/CommentForm.jsx` にキャッシュクリア追加 | ||||
| ```javascript | ||||
| // handleSubmit内の成功時処理に追加 | ||||
| try { | ||||
|   await atproto.putRecord(null, record, agent) | ||||
|  | ||||
|   // キャッシュを無効化 | ||||
|   collections.invalidateCache(env.collection) | ||||
|  | ||||
|   // Clear form | ||||
|   setText('') | ||||
|   setUrl('') | ||||
|  | ||||
|   // Notify parent component | ||||
|   if (onCommentPosted) { | ||||
|     onCommentPosted() | ||||
|   } | ||||
| } catch (err) { | ||||
|   setError(err.message) | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## 3. ローディング改善(20分で実装) | ||||
|  | ||||
| ### ファイル作成: `src/components/LoadingSkeleton.jsx` | ||||
| ```javascript | ||||
| 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> | ||||
|   ) | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 修正: `src/components/RecordTabs.jsx` | ||||
| ```javascript | ||||
| import LoadingSkeleton from './LoadingSkeleton.jsx' | ||||
|  | ||||
| export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) { | ||||
|   const [activeTab, setActiveTab] = useState('lang') | ||||
|  | ||||
|   // ... 既存のロジック | ||||
|  | ||||
|   return ( | ||||
|     <div className="record-tabs"> | ||||
|       <div className="tab-header"> | ||||
|         <button  | ||||
|           className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`} | ||||
|           onClick={() => setActiveTab('lang')} | ||||
|         > | ||||
|           Lang Records ({filteredLangRecords?.length || 0}) | ||||
|         </button> | ||||
|         <button  | ||||
|           className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`} | ||||
|           onClick={() => setActiveTab('comment')} | ||||
|         > | ||||
|           Comment Records ({filteredCommentRecords?.length || 0}) | ||||
|         </button> | ||||
|         <button  | ||||
|           className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`} | ||||
|           onClick={() => setActiveTab('collection')} | ||||
|         > | ||||
|           Collection ({filteredChatRecords?.length || 0}) | ||||
|         </button> | ||||
|         <button  | ||||
|           className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`} | ||||
|           onClick={() => setActiveTab('users')} | ||||
|         > | ||||
|           User Comments ({filteredUserComments?.length || 0}) | ||||
|         </button> | ||||
|       </div> | ||||
|  | ||||
|       <div className="tab-content"> | ||||
|         {activeTab === 'lang' && ( | ||||
|           !langRecords ? ( | ||||
|             <LoadingSkeleton count={3} showTitle={true} /> | ||||
|           ) : ( | ||||
|             <RecordList  | ||||
|               title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"} | ||||
|               records={filteredLangRecords}  | ||||
|               apiConfig={apiConfig}  | ||||
|             /> | ||||
|           ) | ||||
|         )} | ||||
|          | ||||
|         {activeTab === 'comment' && ( | ||||
|           !commentRecords ? ( | ||||
|             <LoadingSkeleton count={3} showTitle={true} /> | ||||
|           ) : ( | ||||
|             <RecordList  | ||||
|               title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"} | ||||
|               records={filteredCommentRecords}  | ||||
|               apiConfig={apiConfig}  | ||||
|             /> | ||||
|           ) | ||||
|         )} | ||||
|          | ||||
|         {activeTab === 'collection' && ( | ||||
|           !chatRecords ? ( | ||||
|             <LoadingSkeleton count={2} showTitle={true} /> | ||||
|           ) : ( | ||||
|             <RecordList  | ||||
|               title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"} | ||||
|               records={filteredChatRecords}  | ||||
|               apiConfig={apiConfig}  | ||||
|             /> | ||||
|           ) | ||||
|         )} | ||||
|          | ||||
|         {activeTab === 'users' && ( | ||||
|           !userComments ? ( | ||||
|             <LoadingSkeleton count={3} showTitle={true} /> | ||||
|           ) : ( | ||||
|             <RecordList  | ||||
|               title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"} | ||||
|               records={filteredUserComments}  | ||||
|               apiConfig={apiConfig}  | ||||
|             /> | ||||
|           ) | ||||
|         )} | ||||
|       </div> | ||||
|  | ||||
|       {/* 既存のstyle... */} | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### 修正: `src/App.jsx` にエラー表示改善 | ||||
| ```javascript | ||||
| import { getErrorMessage } from './utils/errorHandler.js' | ||||
|  | ||||
| export default function App() { | ||||
|   const { user, agent, loading: authLoading, login, logout } = useAuth() | ||||
|   const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData() | ||||
|   const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData) | ||||
|   const pageContext = usePageContext() | ||||
|  | ||||
|   // ... 既存のロジック | ||||
|  | ||||
|   if (error) { | ||||
|     return ( | ||||
|       <div style={{ padding: '20px', textAlign: 'center' }}> | ||||
|         <h1>ATProto OAuth Demo</h1> | ||||
|         <div style={{  | ||||
|           background: '#fee',  | ||||
|           color: '#c33',  | ||||
|           padding: '15px',  | ||||
|           borderRadius: '5px', | ||||
|           margin: '20px 0', | ||||
|           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' | ||||
|           }} | ||||
|         > | ||||
|           再読み込み | ||||
|         </button> | ||||
|       </div> | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   // ... 既存のレンダリング | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## 実装チェックリスト | ||||
|  | ||||
| ### ✅ Phase 1A: エラーハンドリング(30分) | ||||
| - [ ] `src/utils/errorHandler.js` 作成 | ||||
| - [ ] `src/api/atproto.js` の `request` 関数修正 | ||||
| - [ ] `src/hooks/useAdminData.js` エラーハンドリング追加 | ||||
| - [ ] `src/App.jsx` エラー表示改善 | ||||
|  | ||||
| ### ✅ Phase 1B: キャッシュ(15分) | ||||
| - [ ] `src/utils/cache.js` 作成 | ||||
| - [ ] `src/api/atproto.js` の `collections` にキャッシュ追加 | ||||
| - [ ] `src/components/CommentForm.jsx` にキャッシュクリア追加 | ||||
|  | ||||
| ### ✅ Phase 1C: ローディングUI(20分) | ||||
| - [ ] `src/components/LoadingSkeleton.jsx` 作成 | ||||
| - [ ] `src/components/RecordTabs.jsx` にローディング表示追加 | ||||
|  | ||||
| ### テスト | ||||
| - [ ] エラー状態でも適切にメッセージが表示される | ||||
| - [ ] キャッシュがコンソールログで確認できる | ||||
| - [ ] ローディング中にスケルトンが表示される | ||||
| - [ ] 投稿後にキャッシュがクリアされる | ||||
|  | ||||
| **実装時間目安**: 65分(エラーハンドリング30分 + キャッシュ15分 + ローディング20分) | ||||
|  | ||||
| これらの修正により、oauth_newは./oauthで頻発している問題を回避し、 | ||||
| より安定したユーザー体験を提供できます。 | ||||
| @@ -1,120 +0,0 @@ | ||||
| # OAuth Comment System 開発進捗 - 2025-06-18 | ||||
|  | ||||
| ## 今日完了した項目 | ||||
|  | ||||
| ### ✅ UI改善とスタイリング | ||||
| 1. **ヘッダータイトル削除**: "ai.log"タイトルを削除 | ||||
| 2. **ログインボタンアイコン化**: テキストからBlueskyアイコン `<i class="fab fa-bluesky"></i>` に変更 | ||||
| 3. **Ask AIボタン削除**: 完全に削除 | ||||
| 4. **Testボタン移動**: ページ下部に移動、テキストを小文字に変更 | ||||
| 5. **検索バーレイアウト適用**: 認証セクションに検索バーUIパターンを適用 | ||||
| 6. **ボーダー削除**: 複数の要素からborder-top, border-bottom削除 | ||||
| 7. **ヘッダースペーシング修正**: 左側の余白問題を解決 | ||||
| 8. **CSS競合解決**: クラス名に`oauth-`プレフィックス追加でailogサイトとの競合回避 | ||||
| 9. **パディング統一**: `padding: 20px 0` に統一(デスクトップ・モバイル共通) | ||||
|  | ||||
| ### ✅ 機能実装 | ||||
| 1. **テスト用UI作成**: OAuth認証不要のputRecord機能実装 | ||||
| 2. **JSONビューワー追加**: コメント表示にshow/hideボタン追加 | ||||
| 3. **削除機能追加**: OAuth認証ユーザー用のdeleteボタン実装 | ||||
| 4. **動的アバター取得**: 壊れたアバターURL対応のフォールバック機能 | ||||
| 5. **ブラウザ動作確認**: 全機能の動作テスト完了 | ||||
|  | ||||
| ### ✅ 技術的解決 | ||||
| 1. **DID処理改善**: テスト用の偽DITエラー修正 | ||||
| 2. **Handle処理修正**: 自動`.bsky.social`追加削除 | ||||
| 3. **セッション管理**: createSession機能の修正 | ||||
| 4. **アバターキャッシュ**: 動的取得とキャッシュ機能実装 | ||||
|  | ||||
| ## 現在の技術構成 | ||||
|  | ||||
| ### フロントエンド | ||||
| - **React + Vite**: モダンなSPA構成 | ||||
| - **ATProto OAuth**: Bluesky認証システム | ||||
| - **アバター管理**: 動的取得・フォールバック・キャッシュ | ||||
| - **レスポンシブデザイン**: モバイル・デスクトップ対応 | ||||
|  | ||||
| ### バックエンド連携 | ||||
| - **ATProto API**: PDS通信 | ||||
| - **Collection管理**: `ai.syui.log.chat.comment`等のレコード操作 | ||||
| - **DID解決**: Handle → DID → PDS → Profile取得 | ||||
|  | ||||
| ### CSS設計 | ||||
| - **Prefix命名**: `oauth-`で競合回避 | ||||
| - **統一パディング**: `20px 0`でレイアウト統一 | ||||
| - **ailogスタイル継承**: 親サイトとの一貫性保持 | ||||
|  | ||||
| ## ファイル構成 | ||||
|  | ||||
| ``` | ||||
| oauth_new/ | ||||
| ├── src/ | ||||
| │   ├── App.jsx                 # メインアプリケーション | ||||
| │   ├── App.css                 # 統一スタイル(oauth-プレフィックス) | ||||
| │   ├── components/ | ||||
| │   │   ├── AuthButton.jsx      # Blueskyアイコン認証ボタン | ||||
| │   │   ├── CommentForm.jsx     # コメント投稿フォーム | ||||
| │   │   ├── CommentList.jsx     # コメント一覧表示 | ||||
| │   │   └── TestUI.jsx          # テスト用UI | ||||
| │   └── utils/ | ||||
| │       └── avatarFetcher.js    # アバター動的取得 | ||||
| ├── dist/                       # ビルド成果物 | ||||
| ├── build-minimal.js            # 最小化ビルドスクリプト | ||||
| └── PROGRESS.md                 # 本ファイル | ||||
| ``` | ||||
|  | ||||
| ## 残存課題・継続開発項目 | ||||
|  | ||||
| ### 🔄 現在進行中 | ||||
| - 特になし(基本機能完成) | ||||
|  | ||||
| ### 📋 今後の拡張予定 | ||||
| 1. **AI連携強化** | ||||
|    - ai.gptとの統合 | ||||
|    - AIコメント自動生成 | ||||
|    - 心理分析機能統合 | ||||
|  | ||||
| 2. **パフォーマンス最適化** | ||||
|    - バンドルサイズ削減(現在1.2MB) | ||||
|    - 動的インポート実装 | ||||
|    - キャッシュ戦略改善 | ||||
|  | ||||
| 3. **機能拡張** | ||||
|    - リアルタイム更新 | ||||
|    - 通知システム | ||||
|    - モデレーション機能 | ||||
|    - 多言語対応 | ||||
|  | ||||
| 4. **ai.log統合** | ||||
|    - 静的ブログジェネレーター連携 | ||||
|    - 記事別コメント管理 | ||||
|    - SEO最適化 | ||||
|  | ||||
| ### 🎯 次回セッション予定 | ||||
| 1. ai.gpt連携の詳細設計 | ||||
| 2. パフォーマンス最適化 | ||||
| 3. ai.log本体との統合テスト | ||||
|  | ||||
| ## 技術メモ | ||||
|  | ||||
| ### 重要な解決方法 | ||||
| - **CSS競合**: `oauth-`プレフィックスで名前空間分離 | ||||
| - **アバター問題**: 3段階フォールバック(record → fresh fetch → fallback) | ||||
| - **認証フロー**: session管理とDID-based認証 | ||||
| - **レスポンシブ**: 統一パディングでシンプル化 | ||||
|  | ||||
| ### 設定ファイル連携 | ||||
| - `./my-blog/config.toml`: ブログ設定 | ||||
| - `./oauth/.env.production`: OAuth設定 | ||||
| - `~/.config/syui/ai/log/config.json`: システム設定 | ||||
|  | ||||
| ## 成果物 | ||||
|  | ||||
| ✅ **完全に動作するOAuthコメントシステム** | ||||
| - ATProto認証 | ||||
| - コメント投稿・表示・削除 | ||||
| - アバター表示 | ||||
| - JSON詳細表示 | ||||
| - テスト機能 | ||||
| - レスポンシブデザイン | ||||
| - ailogサイトとの統合準備完了 | ||||
							
								
								
									
										222
									
								
								oauth/README.md
									
									
									
									
									
								
							
							
						
						
									
										222
									
								
								oauth/README.md
									
									
									
									
									
								
							| @@ -1,222 +0,0 @@ | ||||
| # ATProto OAuth Comment System | ||||
|  | ||||
| ATProtocol(Bluesky)のOAuth認証を使用したコメントシステムです。 | ||||
|  | ||||
| ## プロジェクト概要 | ||||
|  | ||||
| このプロジェクトは、ATProtocolネットワーク上のコメントとlangレコードを表示するWebアプリケーションです。 | ||||
| - 標準的なOAuth認証画面を使用 | ||||
| - タブ切り替えでレコード表示 | ||||
| - ページコンテキストに応じたフィルタリング | ||||
|  | ||||
| ## ファイル構成 | ||||
|  | ||||
| ``` | ||||
| src/ | ||||
| ├── config/ | ||||
| │   └── env.js              # 環境変数の一元管理 | ||||
| ├── utils/ | ||||
| │   └── pds.js              # PDS判定・API設定ユーティリティ | ||||
| ├── api/ | ||||
| │   └── atproto.js          # ATProto API クライアント | ||||
| ├── hooks/ | ||||
| │   ├── useAuth.js          # OAuth認証フック | ||||
| │   ├── useAdminData.js     # 管理者データ取得フック | ||||
| │   └── usePageContext.js   # ページ判定フック | ||||
| ├── services/ | ||||
| │   └── oauth.js            # OAuth認証サービス | ||||
| ├── components/ | ||||
| │   ├── AuthButton.jsx      # ログイン/ログアウトボタン | ||||
| │   ├── RecordTabs.jsx      # Lang/Commentタブ切り替え | ||||
| │   ├── RecordList.jsx      # レコード表示リスト | ||||
| │   ├── UserLookup.jsx      # ユーザー検索(未使用) | ||||
| │   └── OAuthCallback.jsx   # OAuth コールバック処理 | ||||
| └── App.jsx                 # メインアプリケーション | ||||
| ``` | ||||
|  | ||||
| ## 環境設定 | ||||
|  | ||||
| ### .env ファイル | ||||
|  | ||||
| ```bash | ||||
| VITE_ADMIN=ai.syui.ai                                    # 管理者ハンドル | ||||
| VITE_PDS=syu.is                                         # デフォルトPDS | ||||
| VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"] # syu.is系ハンドルリスト | ||||
| VITE_COLLECTION=ai.syui.log                             # ベースコレクション | ||||
| VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json # OAuth クライアントID | ||||
| VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback   # OAuth リダイレクトURI | ||||
| ``` | ||||
|  | ||||
| ### 必要な依存関係 | ||||
|  | ||||
| ```json | ||||
| { | ||||
|   "dependencies": { | ||||
|     "@atproto/api": "^0.15.12", | ||||
|     "@atproto/oauth-client-browser": "^0.3.19", | ||||
|     "react": "^18.2.0", | ||||
|     "react-dom": "^18.2.0" | ||||
|   } | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## 主要機能 | ||||
|  | ||||
| ### 1. OAuth認証システム | ||||
|  | ||||
| **実装場所**: `src/services/oauth.js` | ||||
|  | ||||
| - `@atproto/oauth-client-browser`を使用した標準OAuth実装 | ||||
| - bsky.social と syu.is 両方のPDSに対応 | ||||
| - セッション自動復元機能 | ||||
|  | ||||
| **重要**: ATProtoのセッション管理は複雑なため、公式ライブラリの使用が必須です。 | ||||
|  | ||||
| ### 2. PDS判定システム | ||||
|  | ||||
| **実装場所**: `src/utils/pds.js` | ||||
|  | ||||
| ```javascript | ||||
| // ハンドル判定ロジック | ||||
| isSyuIsHandle(handle) → boolean | ||||
| // PDS設定取得 | ||||
| getApiConfig(pds) → { pds, bsky, plc, web } | ||||
| ``` | ||||
|  | ||||
| 環境変数`VITE_HANDLE_LIST`と`VITE_PDS`を基に自動判定します。 | ||||
|  | ||||
| ### 3. コレクション取得システム | ||||
|  | ||||
| **実装場所**: `src/api/atproto.js` | ||||
|  | ||||
| ```javascript | ||||
| // 基本コレクション | ||||
| collections.getBase(pds, repo, collection) | ||||
| // lang コレクション(翻訳系) | ||||
| collections.getLang(pds, repo, collection) // → {collection}.chat.lang | ||||
| // comment コレクション(コメント系) | ||||
| collections.getComment(pds, repo, collection) // → {collection}.chat.comment | ||||
| ``` | ||||
|  | ||||
| ### 4. ページコンテキスト判定 | ||||
|  | ||||
| **実装場所**: `src/hooks/usePageContext.js` | ||||
|  | ||||
| ```javascript | ||||
| // URL解析結果 | ||||
| { | ||||
|   isTopPage: boolean,     // トップページかどうか | ||||
|   rkey: string | null,    // 個別ページのrkey(/posts/xxx → xxx) | ||||
|   url: string             // 現在のURL | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ## 表示ロジック | ||||
|  | ||||
| ### フィルタリング | ||||
|  | ||||
| 1. **トップページ**: 最新3件を表示 | ||||
| 2. **個別ページ**: `record.value.post.url`の rkey が現在ページと一致するもののみ表示 | ||||
|  | ||||
| ### タブ切り替え | ||||
|  | ||||
| - Lang Records: `{collection}.chat.lang` | ||||
| - Comment Records: `{collection}.chat.comment` | ||||
|  | ||||
| ## 開発・デバッグ | ||||
|  | ||||
| ### 起動コマンド | ||||
|  | ||||
| ```bash | ||||
| npm install | ||||
| npm run dev    # 開発サーバー | ||||
| npm run build  # プロダクションビルド | ||||
| ``` | ||||
|  | ||||
| ### OAuth デバッグ | ||||
|  | ||||
| 1. **ローカル開発**: 自動的にloopback clientが使用される | ||||
| 2. **本番環境**: `client-metadata.json`が必要 | ||||
|  | ||||
| ```json | ||||
| // public/client-metadata.json | ||||
| { | ||||
|   "client_id": "https://syui.ai/client-metadata.json", | ||||
|   "client_name": "ATProto Comment System", | ||||
|   "redirect_uris": ["https://syui.ai/oauth/callback"], | ||||
|   "scope": "atproto", | ||||
|   "grant_types": ["authorization_code", "refresh_token"], | ||||
|   "response_types": ["code"], | ||||
|   "token_endpoint_auth_method": "none", | ||||
|   "application_type": "web", | ||||
|   "dpop_bound_access_tokens": true | ||||
| } | ||||
| ``` | ||||
|  | ||||
| ### よくある問題 | ||||
|  | ||||
| 1. **セッションが保存されない** | ||||
|    - `@atproto/oauth-client-browser`のバージョン確認 | ||||
|    - IndexedDBの確認(ブラウザの開発者ツール) | ||||
|  | ||||
| 2. **PDS判定が正しく動作しない** | ||||
|    - `VITE_HANDLE_LIST`の JSON 形式を確認 | ||||
|    - 環境変数の読み込み確認 | ||||
|  | ||||
| 3. **レコードが表示されない** | ||||
|    - 管理者アカウントの DID 解決確認 | ||||
|    - コレクション名の確認(`{base}.chat.lang`, `{base}.chat.comment`) | ||||
|  | ||||
| ## API エンドポイント | ||||
|  | ||||
| ### 使用しているATProto API | ||||
|  | ||||
| 1. **com.atproto.repo.describeRepo** | ||||
|    - ハンドル → DID, PDS解決 | ||||
|  | ||||
| 2. **app.bsky.actor.getProfile** | ||||
|    - プロフィール情報取得 | ||||
|  | ||||
| 3. **com.atproto.repo.listRecords** | ||||
|    - コレクションレコード取得 | ||||
|  | ||||
| ## セキュリティ | ||||
|  | ||||
| - OAuth 2.1 + PKCE による認証 | ||||
| - DPoP (Demonstration of Proof of Possession) 対応 | ||||
| - セッション情報はブラウザのIndexedDBに暗号化保存 | ||||
|  | ||||
| ## 今後の拡張可能性 | ||||
|  | ||||
| 1. **コメント投稿機能** | ||||
|    - 認証済みユーザーによるコメント作成 | ||||
|    - `com.atproto.repo.putRecord` API使用 | ||||
|  | ||||
| 2. **リアルタイム更新** | ||||
|    - Jetstream WebSocket 接続 | ||||
|    - 新しいレコードの自動表示 | ||||
|  | ||||
| 3. **マルチPDS対応** | ||||
|    - より多くのPDSへの対応 | ||||
|    - 動的PDS判定の改善 | ||||
|  | ||||
| ## トラブルシューティング | ||||
|  | ||||
| ### ログ確認 | ||||
| ブラウザの開発者ツールでコンソールログを確認してください。主要なエラーは以下の通りです: | ||||
|  | ||||
| - `OAuth initialization failed`: OAuth設定の問題 | ||||
| - `Failed to load admin data`: API アクセスエラー | ||||
| - `Auth check failed`: セッション復元エラー | ||||
|  | ||||
| ### 環境変数確認 | ||||
| ```javascript | ||||
| // 開発者ツールのコンソールで確認 | ||||
| console.log(import.meta.env) | ||||
| ``` | ||||
|  | ||||
| ## 参考資料 | ||||
|  | ||||
| - [ATProto OAuth Guide](https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md) | ||||
| - [BrowserOAuthClient Documentation](https://github.com/bluesky-social/atproto/tree/main/packages/oauth-client-browser) | ||||
| - [ATProto API Reference](https://docs.bsky.app/docs/advanced-guides/atproto-api) | ||||
| @@ -1,41 +0,0 @@ | ||||
| name: Cleanup Old Deployments | ||||
|  | ||||
| on: | ||||
|   workflow_run: | ||||
|     workflows: ["Deploy to Cloudflare Pages"] | ||||
|     types: | ||||
|       - completed | ||||
|   workflow_dispatch: | ||||
|  | ||||
| env: | ||||
|   KEEP_DEPLOYMENTS: 5  # 保持するデプロイメント数 | ||||
|  | ||||
| jobs: | ||||
|   cleanup: | ||||
|     runs-on: ubuntu-latest | ||||
|     if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} | ||||
|      | ||||
|     steps: | ||||
|       - name: Cleanup old deployments | ||||
|         run: | | ||||
|           # Get all deployments | ||||
|           DEPLOYMENTS=$(curl -s -X GET \ | ||||
|             "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \ | ||||
|             -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ | ||||
|             -H "Content-Type: application/json") | ||||
|            | ||||
|           # Extract deployment IDs (skip the latest N deployments) | ||||
|           DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id") | ||||
|            | ||||
|           # Delete old deployments | ||||
|           for ID in $DEPLOYMENT_IDS; do | ||||
|             echo "Deleting deployment: $ID" | ||||
|             curl -s -X DELETE \ | ||||
|               "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \ | ||||
|               -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ | ||||
|               -H "Content-Type: application/json" | ||||
|             echo "Deleted deployment: $ID" | ||||
|             sleep 1  # Rate limiting | ||||
|           done | ||||
|            | ||||
|           echo "Cleanup completed!" | ||||
		Reference in New Issue
	
	Block a user