diff --git a/oauth_new/IMPLEMENTATION_GUIDE.md b/oauth_new/IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..45c062c --- /dev/null +++ b/oauth_new/IMPLEMENTATION_GUIDE.md @@ -0,0 +1,444 @@ +# 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 ( +
+ {Array(count).fill(0).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} + + +
+ ) +} +``` + +**修正**: `src/components/RecordTabs.jsx` +```javascript +import LoadingSkeleton from './LoadingSkeleton.jsx' + +// RecordTabsコンポーネント内 +{activeTab === 'lang' && ( + loading ? ( + + ) : ( + + ) +)} +``` + +### 中期実装(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で発生している「同じ問題の繰り返し」を避け、 +安定した成長可能なシステムが構築できます。 \ No newline at end of file diff --git a/oauth_new/IMPROVEMENT_PLAN.md b/oauth_new/IMPROVEMENT_PLAN.md new file mode 100644 index 0000000..8404ff0 --- /dev/null +++ b/oauth_new/IMPROVEMENT_PLAN.md @@ -0,0 +1,448 @@ +# 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 }) => ( +
+ {Array(count).fill(0).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+) + +// 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の問題を回避しながら、 +より安定した高性能なシステムに進化できます。 \ No newline at end of file diff --git a/oauth_new/PHASE1_QUICK_FIXES.md b/oauth_new/PHASE1_QUICK_FIXES.md new file mode 100644 index 0000000..c68f3b5 --- /dev/null +++ b/oauth_new/PHASE1_QUICK_FIXES.md @@ -0,0 +1,601 @@ +# 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 ( +
+ {showTitle && ( +
+
+
+ )} + + {Array(count).fill(0).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} + + +
+ ) +} +``` + +### 修正: `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 ( +
+
+ + + + +
+ +
+ {activeTab === 'lang' && ( + !langRecords ? ( + + ) : ( + + ) + )} + + {activeTab === 'comment' && ( + !commentRecords ? ( + + ) : ( + + ) + )} + + {activeTab === 'collection' && ( + !chatRecords ? ( + + ) : ( + + ) + )} + + {activeTab === 'users' && ( + !userComments ? ( + + ) : ( + + ) + )} +
+ + {/* 既存のstyle... */} +
+ ) +} +``` + +### 修正: `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 ( +
+

ATProto OAuth Demo

+
+

エラー: {error}

+ {retryCount > 0 && ( +

自動リトライ中... ({retryCount}/3)

+ )} +
+ +
+ ) + } + + // ... 既存のレンダリング +} +``` + +## 実装チェックリスト + +### ✅ 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で頻発している問題を回避し、 +より安定したユーザー体験を提供できます。 \ No newline at end of file