test merge
This commit is contained in:
444
oauth_new/IMPLEMENTATION_GUIDE.md
Normal file
444
oauth_new/IMPLEMENTATION_GUIDE.md
Normal file
@ -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 (
|
||||||
|
<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で発生している「同じ問題の繰り返し」を避け、
|
||||||
|
安定した成長可能なシステムが構築できます。
|
448
oauth_new/IMPROVEMENT_PLAN.md
Normal file
448
oauth_new/IMPROVEMENT_PLAN.md
Normal file
@ -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 }) => (
|
||||||
|
<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の問題を回避しながら、
|
||||||
|
より安定した高性能なシステムに進化できます。
|
601
oauth_new/PHASE1_QUICK_FIXES.md
Normal file
601
oauth_new/PHASE1_QUICK_FIXES.md
Normal file
@ -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 (
|
||||||
|
<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で頻発している問題を回避し、
|
||||||
|
より安定したユーザー体験を提供できます。
|
Reference in New Issue
Block a user