fix oauth package name
This commit is contained in:
601
oauth/PHASE1_QUICK_FIXES.md
Normal file
601
oauth/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