cleanup docs
This commit is contained in:
@ -1,116 +0,0 @@
|
|||||||
# Ask-AI Integration Implementation
|
|
||||||
|
|
||||||
## 概要
|
|
||||||
|
|
||||||
oauth_new アプリに ask-AI 機能を統合しました。この機能により、ユーザーはAIと対話し、その結果を atproto に記録できます。
|
|
||||||
|
|
||||||
## 実装されたファイル
|
|
||||||
|
|
||||||
### 1. `/src/hooks/useAskAI.js`
|
|
||||||
- ask-AI サーバーとの通信機能
|
|
||||||
- atproto への putRecord 機能
|
|
||||||
- チャット履歴の管理
|
|
||||||
- イベント送信(blog との通信用)
|
|
||||||
|
|
||||||
### 2. `/src/components/AskAI.jsx`
|
|
||||||
- チャット UI コンポーネント
|
|
||||||
- 質問入力・回答表示
|
|
||||||
- 認証チェック
|
|
||||||
- IME 対応
|
|
||||||
|
|
||||||
### 3. `/src/App.jsx` の更新
|
|
||||||
- AskAI コンポーネントの統合
|
|
||||||
- Ask AI ボタンの追加
|
|
||||||
- イベントリスナーの設定
|
|
||||||
- blog との通信機能
|
|
||||||
|
|
||||||
## JSON 構造の記録
|
|
||||||
|
|
||||||
`./json/` ディレクトリに各 collection の構造を記録しました:
|
|
||||||
|
|
||||||
- `ai.syui.ai_user.json` - ユーザーリスト
|
|
||||||
- `ai.syui.ai_chat.json` - チャット記録(空)
|
|
||||||
- `syui.syui.ai_chat.json` - チャット記録(実データ)
|
|
||||||
- `ai.syui.ai_chat_lang.json` - 翻訳記録
|
|
||||||
- `ai.syui.ai_chat_comment.json` - コメント記録
|
|
||||||
|
|
||||||
## 実際の ai.syui.log.chat 構造
|
|
||||||
|
|
||||||
確認された実際の構造:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"$type": "ai.syui.log.chat",
|
|
||||||
"post": {
|
|
||||||
"url": "https://syui.ai/",
|
|
||||||
"date": "2025-06-18T02:16:04.609Z",
|
|
||||||
"slug": "",
|
|
||||||
"tags": [],
|
|
||||||
"title": "syui.ai",
|
|
||||||
"language": "ja"
|
|
||||||
},
|
|
||||||
"text": "質問またはAI回答テキスト",
|
|
||||||
"type": "question|answer",
|
|
||||||
"author": {
|
|
||||||
"did": "did:plc:...",
|
|
||||||
"handle": "handle名",
|
|
||||||
"displayName": "表示名",
|
|
||||||
"avatar": "アバターURL"
|
|
||||||
},
|
|
||||||
"createdAt": "2025-06-18T02:16:04.609Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## イベント通信
|
|
||||||
|
|
||||||
blog(ask-ai.js)と OAuth アプリ間の通信:
|
|
||||||
|
|
||||||
### 送信イベント
|
|
||||||
- `postAIQuestion` - blog から OAuth アプリへ質問送信
|
|
||||||
- `aiProfileLoaded` - OAuth アプリから blog へ AI プロフィール送信
|
|
||||||
- `aiResponseReceived` - OAuth アプリから blog へ AI 回答送信
|
|
||||||
|
|
||||||
### 受信イベント
|
|
||||||
- OAuth アプリが `postAIQuestion` を受信して処理
|
|
||||||
- blog が `aiResponseReceived` を受信して表示
|
|
||||||
|
|
||||||
## 環境変数
|
|
||||||
|
|
||||||
```env
|
|
||||||
VITE_ASK_AI_URL=http://localhost:3000/ask # ask-AI サーバーURL(デフォルト)
|
|
||||||
VITE_ADMIN_HANDLE=ai.syui.ai
|
|
||||||
VITE_ATPROTO_PDS=syu.is
|
|
||||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## 機能
|
|
||||||
|
|
||||||
### 実装済み
|
|
||||||
- ✅ ask-AI サーバーとの通信
|
|
||||||
- ✅ atproto への question/answer record 保存
|
|
||||||
- ✅ チャット履歴の表示・管理
|
|
||||||
- ✅ blog との双方向イベント通信
|
|
||||||
- ✅ 認証機能(ログイン必須)
|
|
||||||
- ✅ エラーハンドリング・ローディング状態
|
|
||||||
- ✅ 実際の JSON 構造に合わせた実装
|
|
||||||
|
|
||||||
### 今後のテスト項目
|
|
||||||
- ask-AI サーバーの準備・起動
|
|
||||||
- 実際の質問送信テスト
|
|
||||||
- atproto への putRecord 動作確認
|
|
||||||
- blog からの連携テスト
|
|
||||||
|
|
||||||
## 使用方法
|
|
||||||
|
|
||||||
1. 開発サーバー起動: `npm run dev`
|
|
||||||
2. OAuth ログイン実行
|
|
||||||
3. "Ask AI" ボタンをクリック
|
|
||||||
4. チャット画面で質問入力
|
|
||||||
5. AI の回答が表示され、atproto に記録される
|
|
||||||
|
|
||||||
## 注意事項
|
|
||||||
|
|
||||||
- ask-AI サーバー(VITE_ASK_AI_URL)が必要
|
|
||||||
- 認証されたユーザーのみ質問可能
|
|
||||||
- ai.syui.log.chat への書き込み権限が必要
|
|
||||||
- Production 環境では logger が無効化される
|
|
@ -1,174 +0,0 @@
|
|||||||
# Avatar Fetching System
|
|
||||||
|
|
||||||
This document describes the avatar fetching system implemented for the oauth_new application.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The avatar system provides intelligent avatar fetching with fallback mechanisms, caching, and error handling. It follows the design specified in the project instructions:
|
|
||||||
|
|
||||||
1. **Primary Source**: Try to use avatar from record JSON first
|
|
||||||
2. **Fallback**: If avatar is broken/missing, fetch fresh data from ATProto
|
|
||||||
3. **Fresh Data Flow**: handle → PDS → DID → profile → avatar URI
|
|
||||||
4. **Caching**: Avoid excessive API calls with intelligent caching
|
|
||||||
|
|
||||||
## Files Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── utils/
|
|
||||||
│ └── avatar.js # Core avatar fetching logic
|
|
||||||
├── components/
|
|
||||||
│ ├── Avatar.jsx # React avatar component
|
|
||||||
│ └── AvatarTest.jsx # Test component
|
|
||||||
└── App.css # Avatar styling
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Functions
|
|
||||||
|
|
||||||
### `getAvatar(options)`
|
|
||||||
Main function to fetch avatar with intelligent fallback.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const avatar = await getAvatar({
|
|
||||||
record: recordObject, // Optional: record containing avatar data
|
|
||||||
handle: 'user.handle', // Required if no record
|
|
||||||
did: 'did:plc:xxx', // Optional: user DID
|
|
||||||
forceFresh: false // Optional: force fresh fetch
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### `batchFetchAvatars(users)`
|
|
||||||
Fetch avatars for multiple users in parallel with concurrency control.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const avatarMap = await batchFetchAvatars([
|
|
||||||
{ handle: 'user1.handle', did: 'did:plc:xxx1' },
|
|
||||||
{ handle: 'user2.handle', did: 'did:plc:xxx2' }
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
### `prefetchAvatar(handle)`
|
|
||||||
Prefetch and cache avatar for a specific handle.
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
await prefetchAvatar('user.handle')
|
|
||||||
```
|
|
||||||
|
|
||||||
## React Components
|
|
||||||
|
|
||||||
### `<Avatar>`
|
|
||||||
Basic avatar component with loading states and fallbacks.
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<Avatar
|
|
||||||
record={record}
|
|
||||||
handle="user.handle"
|
|
||||||
did="did:plc:xxx"
|
|
||||||
size={40}
|
|
||||||
showFallback={true}
|
|
||||||
onLoad={() => console.log('loaded')}
|
|
||||||
onError={(err) => console.log('error', err)}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `<AvatarWithCard>`
|
|
||||||
Avatar with hover card showing user information.
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<AvatarWithCard
|
|
||||||
record={record}
|
|
||||||
displayName="User Name"
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
size={60}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### `<AvatarList>`
|
|
||||||
Display multiple avatars with overlap effect.
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<AvatarList
|
|
||||||
users={userArray}
|
|
||||||
maxDisplay={5}
|
|
||||||
size={30}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
1. **Record Check**: Extract avatar from record.value.author.avatar
|
|
||||||
2. **URL Validation**: Verify avatar URL is accessible (HEAD request)
|
|
||||||
3. **Fresh Fetch**: If broken, fetch fresh data:
|
|
||||||
- Get PDS from handle using `getPdsFromHandle()`
|
|
||||||
- Get API config using `getApiConfig()`
|
|
||||||
- Get DID from PDS using `atproto.getDid()`
|
|
||||||
- Get profile from bsky API using `atproto.getProfile()`
|
|
||||||
- Extract avatar from profile
|
|
||||||
4. **Cache**: Store result in cache with 30-minute TTL
|
|
||||||
5. **Fallback**: Show initial-based fallback if no avatar found
|
|
||||||
|
|
||||||
## Caching Strategy
|
|
||||||
|
|
||||||
- **Cache Key**: `avatar:{handle}`
|
|
||||||
- **Duration**: 30 minutes (configurable)
|
|
||||||
- **Cache Provider**: Uses existing `dataCache` utility
|
|
||||||
- **Invalidation**: Manual cache clearing functions available
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- **Network Errors**: Gracefully handled with fallback UI
|
|
||||||
- **Broken URLs**: Automatically detected and re-fetched fresh
|
|
||||||
- **Missing Handles**: Throws descriptive error messages
|
|
||||||
- **API Failures**: Logged but don't break UI
|
|
||||||
|
|
||||||
## Integration
|
|
||||||
|
|
||||||
The avatar system is integrated into the existing RecordList component:
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// Old approach
|
|
||||||
{record.value.author?.avatar && (
|
|
||||||
<img src={record.value.author.avatar} alt="avatar" className="avatar" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
// New approach
|
|
||||||
<Avatar
|
|
||||||
record={record}
|
|
||||||
handle={record.value.author?.handle}
|
|
||||||
did={record.value.author?.did}
|
|
||||||
size={40}
|
|
||||||
showFallback={true}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
The system includes a comprehensive test component (`AvatarTest.jsx`) that can be accessed through the Test UI in the app. It demonstrates:
|
|
||||||
|
|
||||||
1. Avatar from record data
|
|
||||||
2. Avatar from handle only
|
|
||||||
3. Broken avatar URL handling
|
|
||||||
4. Batch fetching
|
|
||||||
5. Prefetch functionality
|
|
||||||
6. Various avatar components
|
|
||||||
|
|
||||||
To test:
|
|
||||||
1. Open the app
|
|
||||||
2. Click "Test" button in header
|
|
||||||
3. Switch to "Avatar System" tab
|
|
||||||
4. Use the test controls to verify functionality
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
- **Concurrent Fetching**: Batch operations use concurrency limits (5 parallel requests)
|
|
||||||
- **Caching**: Reduces API calls by caching results
|
|
||||||
- **Lazy Loading**: Avatar images use lazy loading
|
|
||||||
- **Error Recovery**: Broken avatars are automatically retried with fresh data
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
1. **Persistent Cache**: Consider localStorage for cross-session caching
|
|
||||||
2. **Image Optimization**: Add WebP support and size optimization
|
|
||||||
3. **Preloading**: Implement smarter preloading strategies
|
|
||||||
4. **CDN Integration**: Add CDN support for avatar delivery
|
|
||||||
5. **Placeholder Variations**: More diverse fallback avatar styles
|
|
@ -1,420 +0,0 @@
|
|||||||
# Avatar System Usage Examples
|
|
||||||
|
|
||||||
This document provides practical examples of how to use the avatar fetching system in your components.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
### Simple Avatar Display
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import Avatar from './components/Avatar.jsx'
|
|
||||||
|
|
||||||
function UserProfile({ user }) {
|
|
||||||
return (
|
|
||||||
<div className="user-profile">
|
|
||||||
<Avatar
|
|
||||||
handle={user.handle}
|
|
||||||
did={user.did}
|
|
||||||
size={80}
|
|
||||||
alt={`${user.displayName}'s avatar`}
|
|
||||||
/>
|
|
||||||
<h3>{user.displayName}</h3>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Avatar from Record Data
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
function CommentItem({ record }) {
|
|
||||||
return (
|
|
||||||
<div className="comment">
|
|
||||||
<Avatar
|
|
||||||
record={record}
|
|
||||||
size={40}
|
|
||||||
showFallback={true}
|
|
||||||
/>
|
|
||||||
<div className="comment-content">
|
|
||||||
<strong>{record.value.author.displayName}</strong>
|
|
||||||
<p>{record.value.text}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Avatar with Hover Card
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { AvatarWithCard } from './components/Avatar.jsx'
|
|
||||||
|
|
||||||
function UserList({ users, apiConfig }) {
|
|
||||||
return (
|
|
||||||
<div className="user-list">
|
|
||||||
{users.map(user => (
|
|
||||||
<AvatarWithCard
|
|
||||||
key={user.handle}
|
|
||||||
handle={user.handle}
|
|
||||||
did={user.did}
|
|
||||||
displayName={user.displayName}
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
size={50}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Usage
|
|
||||||
|
|
||||||
### Programmatic Avatar Fetching
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { getAvatar, batchFetchAvatars } from './utils/avatar.js'
|
|
||||||
|
|
||||||
function useUserAvatars(users) {
|
|
||||||
const [avatars, setAvatars] = useState(new Map())
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchAvatars() {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const avatarMap = await batchFetchAvatars(users)
|
|
||||||
setAvatars(avatarMap)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch avatars:', error)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (users.length > 0) {
|
|
||||||
fetchAvatars()
|
|
||||||
}
|
|
||||||
}, [users])
|
|
||||||
|
|
||||||
return { avatars, loading }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
function TeamDisplay({ team }) {
|
|
||||||
const { avatars, loading } = useUserAvatars(team.members)
|
|
||||||
|
|
||||||
if (loading) return <div>Loading team...</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="team">
|
|
||||||
{team.members.map(member => (
|
|
||||||
<img
|
|
||||||
key={member.handle}
|
|
||||||
src={avatars.get(member.handle) || '/default-avatar.png'}
|
|
||||||
alt={member.displayName}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Force Refresh Avatar
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { useState } from 'react'
|
|
||||||
import Avatar from './components/Avatar.jsx'
|
|
||||||
import { getAvatar, clearAvatarCache } from './utils/avatar.js'
|
|
||||||
|
|
||||||
function RefreshableAvatar({ handle, did }) {
|
|
||||||
const [key, setKey] = useState(0)
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
// Clear cache for this user
|
|
||||||
clearAvatarCache(handle)
|
|
||||||
|
|
||||||
// Force re-render of Avatar component
|
|
||||||
setKey(prev => prev + 1)
|
|
||||||
|
|
||||||
// Optionally, prefetch fresh avatar
|
|
||||||
try {
|
|
||||||
await getAvatar({ handle, did, forceFresh: true })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh avatar:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="refreshable-avatar">
|
|
||||||
<Avatar
|
|
||||||
key={key}
|
|
||||||
handle={handle}
|
|
||||||
did={did}
|
|
||||||
size={60}
|
|
||||||
/>
|
|
||||||
<button onClick={handleRefresh}>
|
|
||||||
Refresh Avatar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Avatar List with Overflow
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { AvatarList } from './components/Avatar.jsx'
|
|
||||||
|
|
||||||
function ParticipantsList({ participants, maxVisible = 5 }) {
|
|
||||||
return (
|
|
||||||
<div className="participants">
|
|
||||||
<h4>Participants ({participants.length})</h4>
|
|
||||||
<AvatarList
|
|
||||||
users={participants}
|
|
||||||
maxDisplay={maxVisible}
|
|
||||||
size={32}
|
|
||||||
/>
|
|
||||||
{participants.length > maxVisible && (
|
|
||||||
<span className="overflow-text">
|
|
||||||
and {participants.length - maxVisible} more...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Custom Error Handling
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { useState } from 'react'
|
|
||||||
import Avatar from './components/Avatar.jsx'
|
|
||||||
|
|
||||||
function RobustAvatar({ handle, did, fallbackSrc }) {
|
|
||||||
const [hasError, setHasError] = useState(false)
|
|
||||||
|
|
||||||
const handleError = (error) => {
|
|
||||||
console.warn(`Avatar failed for ${handle}:`, error)
|
|
||||||
setHasError(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasError && fallbackSrc) {
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={fallbackSrc}
|
|
||||||
alt="Fallback avatar"
|
|
||||||
className="avatar"
|
|
||||||
onError={() => setHasError(false)} // Reset on fallback error
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar
|
|
||||||
handle={handle}
|
|
||||||
did={did}
|
|
||||||
onError={handleError}
|
|
||||||
showFallback={!hasError}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Loading States
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { getAvatar } from './utils/avatar.js'
|
|
||||||
|
|
||||||
function AvatarWithCustomLoading({ handle, did }) {
|
|
||||||
const [avatar, setAvatar] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadAvatar() {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
const avatarUrl = await getAvatar({ handle, did })
|
|
||||||
setAvatar(avatarUrl)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAvatar()
|
|
||||||
}, [handle, did])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="avatar-loading-spinner">Loading...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className="avatar-error">Failed to load avatar</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!avatar) {
|
|
||||||
return <div className="avatar-placeholder">No avatar</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
return <img src={avatar} alt="Avatar" className="avatar" />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optimization Patterns
|
|
||||||
|
|
||||||
### Preloading Strategy
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { prefetchAvatar } from './utils/avatar.js'
|
|
||||||
|
|
||||||
function UserCard({ user, isVisible }) {
|
|
||||||
// Preload avatar when component becomes visible
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVisible && user.handle) {
|
|
||||||
prefetchAvatar(user.handle)
|
|
||||||
}
|
|
||||||
}, [isVisible, user.handle])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="user-card">
|
|
||||||
{isVisible && (
|
|
||||||
<Avatar handle={user.handle} did={user.did} />
|
|
||||||
)}
|
|
||||||
<h4>{user.displayName}</h4>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lazy Loading with Intersection Observer
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { useState, useEffect, useRef } from 'react'
|
|
||||||
import Avatar from './components/Avatar.jsx'
|
|
||||||
|
|
||||||
function LazyAvatar({ handle, did, ...props }) {
|
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
|
||||||
const ref = useRef()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setIsVisible(true)
|
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (ref.current) {
|
|
||||||
observer.observe(ref.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref}>
|
|
||||||
{isVisible ? (
|
|
||||||
<Avatar handle={handle} did={did} {...props} />
|
|
||||||
) : (
|
|
||||||
<div className="avatar-placeholder" style={{
|
|
||||||
width: props.size || 40,
|
|
||||||
height: props.size || 40
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cache Management
|
|
||||||
|
|
||||||
### Cache Statistics Display
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { getAvatarCacheStats, cleanupExpiredAvatars } from './utils/avatarCache.js'
|
|
||||||
|
|
||||||
function CacheStatsPanel() {
|
|
||||||
const [stats, setStats] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateStats = () => {
|
|
||||||
setStats(getAvatarCacheStats())
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStats()
|
|
||||||
const interval = setInterval(updateStats, 5000) // Update every 5 seconds
|
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleCleanup = async () => {
|
|
||||||
const cleaned = cleanupExpiredAvatars()
|
|
||||||
alert(`Cleaned ${cleaned} expired cache entries`)
|
|
||||||
setStats(getAvatarCacheStats())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stats) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cache-stats">
|
|
||||||
<h4>Avatar Cache Stats</h4>
|
|
||||||
<p>Cached avatars: {stats.totalCached}</p>
|
|
||||||
<p>Cache hit rate: {stats.hitRate}%</p>
|
|
||||||
<p>Cache hits: {stats.cacheHits}</p>
|
|
||||||
<p>Cache misses: {stats.cacheMisses}</p>
|
|
||||||
<button onClick={handleCleanup}>
|
|
||||||
Clean Expired Cache
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Helpers
|
|
||||||
|
|
||||||
### Mock Avatar for Testing
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// For testing environments
|
|
||||||
const MockAvatar = ({ handle, size = 40, showFallback = true }) => {
|
|
||||||
if (!showFallback) return null
|
|
||||||
|
|
||||||
const initial = (handle || 'U')[0].toUpperCase()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="avatar-mock"
|
|
||||||
style={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: '#e1e1e1',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: size * 0.4,
|
|
||||||
color: '#666'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{initial}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use in tests
|
|
||||||
export default process.env.NODE_ENV === 'test' ? MockAvatar : Avatar
|
|
||||||
```
|
|
||||||
|
|
||||||
These examples demonstrate the flexibility and power of the avatar system while maintaining good performance and user experience practices.
|
|
@ -1,57 +0,0 @@
|
|||||||
name: Deploy to Cloudflare Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
OAUTH_DIR: oauth_new
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
deployments: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: ${{ env.OAUTH_DIR }}/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
cd ${{ env.OAUTH_DIR }}
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
- name: Build OAuth app
|
|
||||||
run: |
|
|
||||||
cd ${{ env.OAUTH_DIR }}
|
|
||||||
NODE_ENV=production npm run build
|
|
||||||
env:
|
|
||||||
VITE_ADMIN: ${{ secrets.VITE_ADMIN }}
|
|
||||||
VITE_PDS: ${{ secrets.VITE_PDS }}
|
|
||||||
VITE_HANDLE_LIST: ${{ secrets.VITE_HANDLE_LIST }}
|
|
||||||
VITE_COLLECTION: ${{ secrets.VITE_COLLECTION }}
|
|
||||||
VITE_OAUTH_CLIENT_ID: ${{ secrets.VITE_OAUTH_CLIENT_ID }}
|
|
||||||
VITE_OAUTH_REDIRECT_URI: ${{ secrets.VITE_OAUTH_REDIRECT_URI }}
|
|
||||||
VITE_ENABLE_TEST_UI: 'false'
|
|
||||||
VITE_ENABLE_DEBUG: 'false'
|
|
||||||
|
|
||||||
- name: Deploy to Cloudflare Pages
|
|
||||||
uses: cloudflare/pages-action@v1
|
|
||||||
with:
|
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
|
||||||
directory: ${{ env.OAUTH_DIR }}/dist
|
|
||||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
deploymentName: Production
|
|
@ -1,104 +0,0 @@
|
|||||||
name: Deploy to Cloudflare Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
OAUTH_DIR: oauth_new
|
|
||||||
KEEP_DEPLOYMENTS: 5 # 保持するデプロイメント数
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
deployments: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: ${{ env.OAUTH_DIR }}/package-lock.json
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
cd ${{ env.OAUTH_DIR }}
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
- name: Build OAuth app
|
|
||||||
run: |
|
|
||||||
cd ${{ env.OAUTH_DIR }}
|
|
||||||
NODE_ENV=production npm run build
|
|
||||||
env:
|
|
||||||
VITE_ADMIN: ${{ secrets.VITE_ADMIN }}
|
|
||||||
VITE_PDS: ${{ secrets.VITE_PDS }}
|
|
||||||
VITE_HANDLE_LIST: ${{ secrets.VITE_HANDLE_LIST }}
|
|
||||||
VITE_COLLECTION: ${{ secrets.VITE_COLLECTION }}
|
|
||||||
VITE_OAUTH_CLIENT_ID: ${{ secrets.VITE_OAUTH_CLIENT_ID }}
|
|
||||||
VITE_OAUTH_REDIRECT_URI: ${{ secrets.VITE_OAUTH_REDIRECT_URI }}
|
|
||||||
VITE_ENABLE_TEST_UI: 'false'
|
|
||||||
VITE_ENABLE_DEBUG: 'false'
|
|
||||||
|
|
||||||
- name: Deploy to Cloudflare Pages
|
|
||||||
uses: cloudflare/pages-action@v1
|
|
||||||
with:
|
|
||||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
|
||||||
directory: ${{ env.OAUTH_DIR }}/dist
|
|
||||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
deploymentName: Production
|
|
||||||
|
|
||||||
cleanup:
|
|
||||||
needs: deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: success()
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Wait for deployment to complete
|
|
||||||
run: sleep 30
|
|
||||||
|
|
||||||
- name: Cleanup old deployments
|
|
||||||
run: |
|
|
||||||
# Get all deployments
|
|
||||||
DEPLOYMENTS=$(curl -s -X GET \
|
|
||||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json")
|
|
||||||
|
|
||||||
# Extract deployment IDs (skip the latest N deployments)
|
|
||||||
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
|
|
||||||
|
|
||||||
if [ -z "$DEPLOYMENT_IDS" ]; then
|
|
||||||
echo "No old deployments to delete"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Delete old deployments
|
|
||||||
for ID in $DEPLOYMENT_IDS; do
|
|
||||||
echo "Deleting deployment: $ID"
|
|
||||||
RESPONSE=$(curl -s -X DELETE \
|
|
||||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json")
|
|
||||||
|
|
||||||
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
|
|
||||||
if [ "$SUCCESS" = "true" ]; then
|
|
||||||
echo "Successfully deleted deployment: $ID"
|
|
||||||
else
|
|
||||||
echo "Failed to delete deployment: $ID"
|
|
||||||
echo "$RESPONSE" | jq .
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 1 # Rate limiting
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Cleanup completed!"
|
|
@ -1,178 +0,0 @@
|
|||||||
# 本番環境デプロイメント手順
|
|
||||||
|
|
||||||
## 本番環境用の調整
|
|
||||||
|
|
||||||
### 1. テスト機能の削除・無効化
|
|
||||||
|
|
||||||
本番環境では以下の調整が必要です:
|
|
||||||
|
|
||||||
#### A. TestUI コンポーネントの削除
|
|
||||||
```jsx
|
|
||||||
// src/App.jsx から以下を削除/コメントアウト
|
|
||||||
import TestUI from './components/TestUI.jsx'
|
|
||||||
const [showTestUI, setShowTestUI] = useState(false)
|
|
||||||
|
|
||||||
// ボトムセクションからTestUIを削除
|
|
||||||
{showTestUI && (
|
|
||||||
<TestUI />
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTestUI(!showTestUI)}
|
|
||||||
className={`btn ${showTestUI ? 'btn-danger' : 'btn-outline'} btn-sm`}
|
|
||||||
>
|
|
||||||
{showTestUI ? 'close test' : 'test'}
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### B. ログ出力の完全無効化
|
|
||||||
現在は `logger.js` で開発環境のみログが有効になっていますが、完全に確実にするため:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 本番ビルド前に全てのconsole.logを確認
|
|
||||||
grep -r "console\." src/ --exclude-dir=node_modules
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 環境変数の設定
|
|
||||||
|
|
||||||
#### 本番用 .env.production
|
|
||||||
```bash
|
|
||||||
VITE_ATPROTO_PDS=syu.is
|
|
||||||
VITE_ADMIN_HANDLE=ai.syui.ai
|
|
||||||
VITE_AI_HANDLE=ai.syui.ai
|
|
||||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. ビルドコマンド
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 本番用ビルド
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 生成されるファイル確認
|
|
||||||
ls -la dist/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. デプロイ用ファイル構成
|
|
||||||
|
|
||||||
```
|
|
||||||
dist/
|
|
||||||
├── index.html # 最小化HTML
|
|
||||||
├── assets/
|
|
||||||
│ ├── comment-atproto-[hash].js # メインJSバンドル
|
|
||||||
│ └── comment-atproto-[hash].css # CSS
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. ailog サイトへの統合
|
|
||||||
|
|
||||||
#### A. アセットファイルのコピー
|
|
||||||
```bash
|
|
||||||
# distファイルをailogサイトの適切な場所にコピー
|
|
||||||
cp dist/assets/* /path/to/ailog/static/assets/
|
|
||||||
cp dist/index.html /path/to/ailog/templates/oauth-assets.html
|
|
||||||
```
|
|
||||||
|
|
||||||
#### B. ailog テンプレートでの読み込み
|
|
||||||
```html
|
|
||||||
<!-- ailog のテンプレートに追加 -->
|
|
||||||
{{ if .Site.Params.oauth_comments }}
|
|
||||||
{{ partial "oauth-assets.html" . }}
|
|
||||||
{{ end }}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 本番環境チェックリスト
|
|
||||||
|
|
||||||
#### ✅ セキュリティ
|
|
||||||
- [ ] OAuth認証のリダイレクトURL確認
|
|
||||||
- [ ] 環境変数の機密情報確認
|
|
||||||
- [ ] HTTPS通信確認
|
|
||||||
|
|
||||||
#### ✅ パフォーマンス
|
|
||||||
- [ ] バンドルサイズ確認(現在1.2MB)
|
|
||||||
- [ ] ファイル圧縮確認
|
|
||||||
- [ ] キャッシュ設定確認
|
|
||||||
|
|
||||||
#### ✅ 機能
|
|
||||||
- [ ] 本番PDS接続確認
|
|
||||||
- [ ] OAuth認証フロー確認
|
|
||||||
- [ ] コメント投稿・表示確認
|
|
||||||
- [ ] アバター表示確認
|
|
||||||
|
|
||||||
#### ✅ UI/UX
|
|
||||||
- [ ] モバイル表示確認
|
|
||||||
- [ ] アクセシビリティ確認
|
|
||||||
- [ ] エラーハンドリング確認
|
|
||||||
|
|
||||||
### 7. 段階的デプロイ戦略
|
|
||||||
|
|
||||||
#### Phase 1: テスト環境
|
|
||||||
```bash
|
|
||||||
# テスト用のサブドメインでデプロイ
|
|
||||||
# test.syui.ai など
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Phase 2: 本番環境
|
|
||||||
```bash
|
|
||||||
# 問題なければ本番環境にデプロイ
|
|
||||||
# ailog本体に統合
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. トラブルシューティング
|
|
||||||
|
|
||||||
#### よくある問題
|
|
||||||
1. **OAuth認証エラー**: リダイレクトURL設定確認
|
|
||||||
2. **PDS接続エラー**: ネットワーク・DNS設定確認
|
|
||||||
3. **アバター表示エラー**: CORS設定確認
|
|
||||||
4. **CSS競合**: oauth-プレフィックス確認
|
|
||||||
|
|
||||||
#### ログ確認方法
|
|
||||||
```bash
|
|
||||||
# 本番環境でエラーが発生した場合
|
|
||||||
# ブラウザのDevToolsでエラー確認
|
|
||||||
# logger.jsは本番では無効化されている
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. 本番用設定ファイル
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ~/.config/syui/ai/log/config.json
|
|
||||||
{
|
|
||||||
"oauth": {
|
|
||||||
"environment": "production",
|
|
||||||
"debug": false,
|
|
||||||
"test_mode": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10. 推奨デプロイ手順
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. テスト機能削除
|
|
||||||
git checkout -b production-ready
|
|
||||||
# App.jsx からTestUI関連を削除
|
|
||||||
|
|
||||||
# 2. 本番ビルド
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 3. ファイル確認
|
|
||||||
ls -la dist/
|
|
||||||
|
|
||||||
# 4. ailogサイトに統合
|
|
||||||
cp dist/assets/* ../my-blog/static/assets/
|
|
||||||
cp dist/index.html ../my-blog/templates/oauth-assets.html
|
|
||||||
|
|
||||||
# 5. ailogサイトでテスト
|
|
||||||
cd ../my-blog
|
|
||||||
hugo server
|
|
||||||
|
|
||||||
# 6. 問題なければcommit
|
|
||||||
git add .
|
|
||||||
git commit -m "Production build: Remove test UI, optimize for deployment"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事項
|
|
||||||
|
|
||||||
- TestUIは開発・デモ用のため本番では削除必須
|
|
||||||
- loggerは自動で本番では無効化される
|
|
||||||
- OAuth設定は本番PDS用に調整必要
|
|
||||||
- バンドルサイズが大きいため今後最適化検討
|
|
@ -1,334 +0,0 @@
|
|||||||
# 開発ガイド
|
|
||||||
|
|
||||||
## 設計思想
|
|
||||||
|
|
||||||
このプロジェクトは以下の原則に基づいて設計されています:
|
|
||||||
|
|
||||||
### 1. 環境変数による設定の外部化
|
|
||||||
- ハードコードを避け、設定は全て環境変数で管理
|
|
||||||
- `src/config/env.js` で一元管理
|
|
||||||
|
|
||||||
### 2. PDS(Personal Data Server)の自動判定
|
|
||||||
- `VITE_HANDLE_LIST` と `VITE_PDS` による自動判定
|
|
||||||
- syu.is系とbsky.social系の自動振り分け
|
|
||||||
|
|
||||||
### 3. コンポーネントの責任分離
|
|
||||||
- Hooks: ビジネスロジック
|
|
||||||
- Components: UI表示のみ
|
|
||||||
- Services: 外部API連携
|
|
||||||
- Utils: 純粋関数
|
|
||||||
|
|
||||||
## アーキテクチャ詳細
|
|
||||||
|
|
||||||
### データフロー
|
|
||||||
|
|
||||||
```
|
|
||||||
User Input
|
|
||||||
↓
|
|
||||||
Hooks (useAuth, useAdminData, usePageContext)
|
|
||||||
↓
|
|
||||||
Services (OAuthService)
|
|
||||||
↓
|
|
||||||
API (atproto.js)
|
|
||||||
↓
|
|
||||||
ATProto Network
|
|
||||||
↓
|
|
||||||
Components (UI Display)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 状態管理
|
|
||||||
|
|
||||||
React Hooksによる状態管理:
|
|
||||||
- `useAuth`: OAuth認証状態
|
|
||||||
- `useAdminData`: 管理者データ(プロフィール、レコード)
|
|
||||||
- `usePageContext`: ページ判定(トップ/個別)
|
|
||||||
|
|
||||||
### OAuth認証フロー
|
|
||||||
|
|
||||||
```
|
|
||||||
1. ユーザーがハンドル入力
|
|
||||||
2. PDS判定 (syu.is vs bsky.social)
|
|
||||||
3. 適切なOAuthClientを選択
|
|
||||||
4. 標準OAuth画面にリダイレクト
|
|
||||||
5. 認証完了後コールバック処理
|
|
||||||
6. セッション復元・保存
|
|
||||||
```
|
|
||||||
|
|
||||||
## 重要な実装詳細
|
|
||||||
|
|
||||||
### セッション管理
|
|
||||||
|
|
||||||
`@atproto/oauth-client-browser`が自動的に以下を処理:
|
|
||||||
- IndexedDBへのセッション保存
|
|
||||||
- トークンの自動更新
|
|
||||||
- DPoP(Demonstration of Proof of Possession)
|
|
||||||
|
|
||||||
**注意**: 手動でのセッション管理は複雑なため、公式ライブラリを使用すること。
|
|
||||||
|
|
||||||
### PDS判定アルゴリズム
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/utils/pds.js
|
|
||||||
function isSyuIsHandle(handle) {
|
|
||||||
return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
1. `VITE_HANDLE_LIST` に含まれるハンドル → syu.is
|
|
||||||
2. `.syu.is` で終わるハンドル → syu.is
|
|
||||||
3. その他 → bsky.social
|
|
||||||
|
|
||||||
### レコードフィルタリング
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/components/RecordTabs.jsx
|
|
||||||
const filterRecords = (records) => {
|
|
||||||
if (pageContext.isTopPage) {
|
|
||||||
return records.slice(0, 3) // 最新3件
|
|
||||||
} else {
|
|
||||||
// URL のrkey と record.value.post.url のrkey を照合
|
|
||||||
return records.filter(record => {
|
|
||||||
const recordRkey = new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '')
|
|
||||||
return recordRkey === pageContext.rkey
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 開発時の注意点
|
|
||||||
|
|
||||||
### 1. 環境変数の命名
|
|
||||||
|
|
||||||
- `VITE_` プレフィックス必須(Viteの制約)
|
|
||||||
- JSON形式の環境変数は文字列として定義
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# ❌ 間違い
|
|
||||||
VITE_HANDLE_LIST=["ai.syui.ai"]
|
|
||||||
|
|
||||||
# ✅ 正しい
|
|
||||||
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. API エラーハンドリング
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/api/atproto.js
|
|
||||||
async function request(url) {
|
|
||||||
const response = await fetch(url)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`)
|
|
||||||
}
|
|
||||||
return await response.json()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
すべてのAPI呼び出しでエラーハンドリングを実装。
|
|
||||||
|
|
||||||
### 3. コンポーネント設計
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ❌ Bad: ビジネスロジックがコンポーネント内
|
|
||||||
function MyComponent() {
|
|
||||||
const [data, setData] = useState([])
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/data').then(setData)
|
|
||||||
}, [])
|
|
||||||
return <div>{data.map(...)}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Good: Hooksでロジック分離
|
|
||||||
function MyComponent() {
|
|
||||||
const { data, loading, error } = useMyData()
|
|
||||||
if (loading) return <Loading />
|
|
||||||
if (error) return <Error />
|
|
||||||
return <div>{data.map(...)}</div>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## デバッグ手法
|
|
||||||
|
|
||||||
### 1. OAuth デバッグ
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ブラウザの開発者ツールで確認
|
|
||||||
localStorage.clear() // セッションクリア
|
|
||||||
sessionStorage.clear() // 一時データクリア
|
|
||||||
|
|
||||||
// IndexedDB確認(Application タブ)
|
|
||||||
// ATProtoの認証データが保存される
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. PDS判定デバッグ
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/utils/pds.js にログ追加
|
|
||||||
console.log('Handle:', handle)
|
|
||||||
console.log('Is syu.is:', isSyuIsHandle(handle))
|
|
||||||
console.log('API Config:', getApiConfig(pds))
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. レコードフィルタリングデバッグ
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/components/RecordTabs.jsx
|
|
||||||
console.log('Page Context:', pageContext)
|
|
||||||
console.log('All Records:', records.length)
|
|
||||||
console.log('Filtered Records:', filteredRecords.length)
|
|
||||||
```
|
|
||||||
|
|
||||||
## パフォーマンス最適化
|
|
||||||
|
|
||||||
### 1. 並列データ取得
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/hooks/useAdminData.js
|
|
||||||
const [records, lang, comment] = await Promise.all([
|
|
||||||
collections.getBase(apiConfig.pds, did, env.collection),
|
|
||||||
collections.getLang(apiConfig.pds, did, env.collection),
|
|
||||||
collections.getComment(apiConfig.pds, did, env.collection)
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 不要な再レンダリング防止
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// useMemo でフィルタリング結果をキャッシュ
|
|
||||||
const filteredRecords = useMemo(() =>
|
|
||||||
filterRecords(records),
|
|
||||||
[records, pageContext]
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## テスト戦略
|
|
||||||
|
|
||||||
### 1. 単体テスト推奨対象
|
|
||||||
|
|
||||||
- `src/utils/pds.js` - PDS判定ロジック
|
|
||||||
- `src/config/env.js` - 環境変数パース
|
|
||||||
- フィルタリング関数
|
|
||||||
|
|
||||||
### 2. 統合テスト推奨対象
|
|
||||||
|
|
||||||
- OAuth認証フロー
|
|
||||||
- API呼び出し
|
|
||||||
- レコード表示
|
|
||||||
|
|
||||||
## デプロイメント
|
|
||||||
|
|
||||||
### 1. 必要ファイル
|
|
||||||
|
|
||||||
```
|
|
||||||
public/
|
|
||||||
└── client-metadata.json # OAuth設定ファイル
|
|
||||||
|
|
||||||
dist/ # ビルド出力
|
|
||||||
├── index.html
|
|
||||||
└── assets/
|
|
||||||
├── comment-atproto-[hash].js
|
|
||||||
└── comment-atproto-[hash].css
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. デプロイ手順
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 環境変数設定
|
|
||||||
cp .env.example .env
|
|
||||||
# 2. 本番用設定を記入
|
|
||||||
# 3. ビルド
|
|
||||||
npm run build
|
|
||||||
# 4. dist/ フォルダをデプロイ
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 本番環境チェックリスト
|
|
||||||
|
|
||||||
- [ ] `.env` ファイルの本番設定
|
|
||||||
- [ ] `client-metadata.json` の設置
|
|
||||||
- [ ] HTTPS 必須(OAuth要件)
|
|
||||||
- [ ] CSP(Content Security Policy)設定
|
|
||||||
|
|
||||||
## よくある問題と解決法
|
|
||||||
|
|
||||||
### 1. "OAuth initialization failed"
|
|
||||||
|
|
||||||
**原因**: client-metadata.json が見つからない、または形式が正しくない
|
|
||||||
|
|
||||||
**解決法**:
|
|
||||||
```bash
|
|
||||||
# public/client-metadata.json の存在確認
|
|
||||||
ls -la public/client-metadata.json
|
|
||||||
|
|
||||||
# 形式確認(JSON validation)
|
|
||||||
jq . public/client-metadata.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. "Failed to load admin data"
|
|
||||||
|
|
||||||
**原因**: 管理者アカウントのDID解決に失敗
|
|
||||||
|
|
||||||
**解決法**:
|
|
||||||
```bash
|
|
||||||
# 手動でDID解決確認
|
|
||||||
curl "https://syu.is/xrpc/com.atproto.repo.describeRepo?repo=ai.syui.ai"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. レコードが表示されない
|
|
||||||
|
|
||||||
**原因**: コレクション名の不一致、権限不足
|
|
||||||
|
|
||||||
**解決法**:
|
|
||||||
```bash
|
|
||||||
# コレクション確認
|
|
||||||
curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collection=ai.syui.log.chat.lang"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 機能拡張ガイド
|
|
||||||
|
|
||||||
### 1. 新しいコレクション追加
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/api/atproto.js に追加
|
|
||||||
export const collections = {
|
|
||||||
// 既存...
|
|
||||||
async getNewCollection(pds, repo, collection, limit = 10) {
|
|
||||||
return await atproto.getRecords(pds, repo, `${collection}.new`, limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 新しいPDS対応
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/utils/pds.js を拡張
|
|
||||||
export function getApiConfig(pds) {
|
|
||||||
if (pds.includes('syu.is')) {
|
|
||||||
// 既存の syu.is 設定
|
|
||||||
} else if (pds.includes('newpds.com')) {
|
|
||||||
return {
|
|
||||||
pds: `https://newpds.com`,
|
|
||||||
bsky: `https://bsky.newpds.com`,
|
|
||||||
plc: `https://plc.newpds.com`,
|
|
||||||
web: `https://web.newpds.com`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// デフォルト設定
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. リアルタイム更新追加
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// src/hooks/useRealtimeUpdates.js
|
|
||||||
export function useRealtimeUpdates(collection) {
|
|
||||||
useEffect(() => {
|
|
||||||
const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data)
|
|
||||||
if (data.collection === collection) {
|
|
||||||
// 新しいレコードを追加
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return () => ws.close()
|
|
||||||
}, [collection])
|
|
||||||
}
|
|
||||||
```
|
|
@ -1,110 +0,0 @@
|
|||||||
# 環境変数による機能切り替え
|
|
||||||
|
|
||||||
## 概要
|
|
||||||
|
|
||||||
開発用機能(TestUI、デバッグログ)をenv変数で簡単に有効/無効化できるようになりました。
|
|
||||||
|
|
||||||
## 設定ファイル
|
|
||||||
|
|
||||||
### 開発環境用: `.env`
|
|
||||||
```bash
|
|
||||||
# Development/Debug features
|
|
||||||
VITE_ENABLE_TEST_UI=true
|
|
||||||
VITE_ENABLE_DEBUG=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### 本番環境用: `.env.production`
|
|
||||||
```bash
|
|
||||||
# Production settings - Disable development features
|
|
||||||
VITE_ENABLE_TEST_UI=false
|
|
||||||
VITE_ENABLE_DEBUG=false
|
|
||||||
```
|
|
||||||
|
|
||||||
## 制御される機能
|
|
||||||
|
|
||||||
### 1. TestUI コンポーネント
|
|
||||||
- **制御**: `VITE_ENABLE_TEST_UI`
|
|
||||||
- **true**: TestボタンとTestUI表示
|
|
||||||
- **false**: TestUI関連が完全に非表示
|
|
||||||
|
|
||||||
### 2. デバッグログ
|
|
||||||
- **制御**: `VITE_ENABLE_DEBUG`
|
|
||||||
- **true**: console.log等が有効
|
|
||||||
- **false**: すべてのlogが無効化
|
|
||||||
|
|
||||||
## 使い方
|
|
||||||
|
|
||||||
### 開発時
|
|
||||||
```bash
|
|
||||||
# .envで有効化されているので通常通り
|
|
||||||
npm run dev
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 本番デプロイ時
|
|
||||||
```bash
|
|
||||||
# 自動的に .env.production が読み込まれる
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# または明示的に指定
|
|
||||||
NODE_ENV=production npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 手動切り替え
|
|
||||||
```bash
|
|
||||||
# 一時的にTestUIだけ無効化
|
|
||||||
VITE_ENABLE_TEST_UI=false npm run dev
|
|
||||||
|
|
||||||
# 一時的にデバッグだけ無効化
|
|
||||||
VITE_ENABLE_DEBUG=false npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## 実装詳細
|
|
||||||
|
|
||||||
### App.jsx
|
|
||||||
```jsx
|
|
||||||
// Environment-based feature flags
|
|
||||||
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
|
|
||||||
const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true'
|
|
||||||
|
|
||||||
// TestUI表示制御
|
|
||||||
{ENABLE_TEST_UI && showTestUI && (
|
|
||||||
<div className="test-section">
|
|
||||||
<TestUI />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
// Testボタン表示制御
|
|
||||||
{ENABLE_TEST_UI && (
|
|
||||||
<div className="bottom-actions">
|
|
||||||
<button onClick={() => setShowTestUI(!showTestUI)}>
|
|
||||||
{showTestUI ? 'close test' : 'test'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
### logger.js
|
|
||||||
```jsx
|
|
||||||
class Logger {
|
|
||||||
constructor() {
|
|
||||||
this.isDev = import.meta.env.DEV || false
|
|
||||||
this.debugEnabled = import.meta.env.VITE_ENABLE_DEBUG === 'true'
|
|
||||||
this.isEnabled = this.isDev && this.debugEnabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## メリット
|
|
||||||
|
|
||||||
✅ **コード削除不要**: 機能を残したまま本番で無効化
|
|
||||||
✅ **簡単切り替え**: env変数だけで制御
|
|
||||||
✅ **自動化対応**: CI/CDで環境別自動ビルド可能
|
|
||||||
✅ **デバッグ容易**: 必要時に即座に有効化可能
|
|
||||||
|
|
||||||
## 本番デプロイチェックリスト
|
|
||||||
|
|
||||||
- [ ] `.env.production`でTestUI無効化確認
|
|
||||||
- [ ] `.env.production`でデバッグ無効化確認
|
|
||||||
- [ ] 本番ビルドでTestボタン非表示確認
|
|
||||||
- [ ] 本番でconsole.log出力なし確認
|
|
@ -1,444 +0,0 @@
|
|||||||
# OAuth_new 実装ガイド
|
|
||||||
|
|
||||||
## Claude Code用実装指示
|
|
||||||
|
|
||||||
### 即座に実装可能な改善(優先度:最高)
|
|
||||||
|
|
||||||
#### 1. エラーハンドリング強化
|
|
||||||
|
|
||||||
**ファイル**: `src/utils/errorHandler.js` (新規作成)
|
|
||||||
```javascript
|
|
||||||
export class ATProtoError extends Error {
|
|
||||||
constructor(message, status, context) {
|
|
||||||
super(message)
|
|
||||||
this.status = status
|
|
||||||
this.context = context
|
|
||||||
this.timestamp = new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getErrorMessage(error) {
|
|
||||||
if (error.status === 400) {
|
|
||||||
return 'アカウントまたはコレクションが見つかりません'
|
|
||||||
} else if (error.status === 429) {
|
|
||||||
return 'レート制限です。しばらく待ってから再試行してください'
|
|
||||||
} else if (error.status === 500) {
|
|
||||||
return 'サーバーエラーが発生しました'
|
|
||||||
} else if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) {
|
|
||||||
return 'ネットワーク接続を確認してください'
|
|
||||||
} else if (error.message.includes('timeout')) {
|
|
||||||
return 'タイムアウトしました。再試行してください'
|
|
||||||
}
|
|
||||||
return '予期しないエラーが発生しました'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logError(error, context) {
|
|
||||||
console.error(`[ATProto Error] ${context}:`, {
|
|
||||||
message: error.message,
|
|
||||||
status: error.status,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**修正**: `src/api/atproto.js`
|
|
||||||
```javascript
|
|
||||||
import { ATProtoError, logError } from '../utils/errorHandler.js'
|
|
||||||
|
|
||||||
async function request(url, options = {}) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, options)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new ATProtoError(
|
|
||||||
`HTTP ${response.status}: ${response.statusText}`,
|
|
||||||
response.status,
|
|
||||||
{ url, options }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ATProtoError) {
|
|
||||||
logError(error, 'API Request')
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network errors
|
|
||||||
const atprotoError = new ATProtoError(
|
|
||||||
'ネットワークエラーが発生しました',
|
|
||||||
0,
|
|
||||||
{ url, originalError: error.message }
|
|
||||||
)
|
|
||||||
logError(atprotoError, 'Network Error')
|
|
||||||
throw atprotoError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**修正**: `src/hooks/useAdminData.js`
|
|
||||||
```javascript
|
|
||||||
import { getErrorMessage, logError } from '../utils/errorHandler.js'
|
|
||||||
|
|
||||||
// loadAdminData関数内のcatchブロック
|
|
||||||
} catch (err) {
|
|
||||||
logError(err, 'useAdminData.loadAdminData')
|
|
||||||
setError(getErrorMessage(err))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. シンプルなキャッシュシステム
|
|
||||||
|
|
||||||
**ファイル**: `src/utils/cache.js` (新規作成)
|
|
||||||
```javascript
|
|
||||||
class SimpleCache {
|
|
||||||
constructor(ttl = 30000) { // 30秒TTL
|
|
||||||
this.cache = new Map()
|
|
||||||
this.ttl = ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key) {
|
|
||||||
const item = this.cache.get(key)
|
|
||||||
if (!item) return null
|
|
||||||
|
|
||||||
if (Date.now() - item.timestamp > this.ttl) {
|
|
||||||
this.cache.delete(key)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return item.data
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key, data) {
|
|
||||||
this.cache.set(key, {
|
|
||||||
data,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.cache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidatePattern(pattern) {
|
|
||||||
for (const key of this.cache.keys()) {
|
|
||||||
if (key.includes(pattern)) {
|
|
||||||
this.cache.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dataCache = new SimpleCache()
|
|
||||||
```
|
|
||||||
|
|
||||||
**修正**: `src/api/atproto.js`
|
|
||||||
```javascript
|
|
||||||
import { dataCache } from '../utils/cache.js'
|
|
||||||
|
|
||||||
export const collections = {
|
|
||||||
async getBase(pds, repo, collection, limit = 10) {
|
|
||||||
const cacheKey = `base:${pds}:${repo}:${collection}`
|
|
||||||
const cached = dataCache.get(cacheKey)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const data = await atproto.getRecords(pds, repo, collection, limit)
|
|
||||||
dataCache.set(cacheKey, data)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getLang(pds, repo, collection, limit = 10) {
|
|
||||||
const cacheKey = `lang:${pds}:${repo}:${collection}`
|
|
||||||
const cached = dataCache.get(cacheKey)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
|
|
||||||
dataCache.set(cacheKey, data)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getComment(pds, repo, collection, limit = 10) {
|
|
||||||
const cacheKey = `comment:${pds}:${repo}:${collection}`
|
|
||||||
const cached = dataCache.get(cacheKey)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
|
|
||||||
dataCache.set(cacheKey, data)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 投稿後にキャッシュをクリア
|
|
||||||
invalidateCache(collection) {
|
|
||||||
dataCache.invalidatePattern(collection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. ローディングスケルトン
|
|
||||||
|
|
||||||
**ファイル**: `src/components/LoadingSkeleton.jsx` (新規作成)
|
|
||||||
```javascript
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
export default function LoadingSkeleton({ count = 3 }) {
|
|
||||||
return (
|
|
||||||
<div className="loading-skeleton">
|
|
||||||
{Array(count).fill(0).map((_, i) => (
|
|
||||||
<div key={i} className="skeleton-item">
|
|
||||||
<div className="skeleton-avatar"></div>
|
|
||||||
<div className="skeleton-content">
|
|
||||||
<div className="skeleton-line"></div>
|
|
||||||
<div className="skeleton-line short"></div>
|
|
||||||
<div className="skeleton-line shorter"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
.loading-skeleton {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
.skeleton-item {
|
|
||||||
display: flex;
|
|
||||||
padding: 15px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
margin: 10px 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
.skeleton-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: loading 1.5s infinite;
|
|
||||||
margin-right: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.skeleton-content {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.skeleton-line {
|
|
||||||
height: 12px;
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: loading 1.5s infinite;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.skeleton-line.short {
|
|
||||||
width: 70%;
|
|
||||||
}
|
|
||||||
.skeleton-line.shorter {
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
@keyframes loading {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**修正**: `src/components/RecordTabs.jsx`
|
|
||||||
```javascript
|
|
||||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
|
||||||
|
|
||||||
// RecordTabsコンポーネント内
|
|
||||||
{activeTab === 'lang' && (
|
|
||||||
loading ? (
|
|
||||||
<LoadingSkeleton count={3} />
|
|
||||||
) : (
|
|
||||||
<RecordList
|
|
||||||
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
|
|
||||||
records={filteredLangRecords}
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 中期実装(1週間以内)
|
|
||||||
|
|
||||||
#### 4. リトライ機能
|
|
||||||
|
|
||||||
**修正**: `src/api/atproto.js`
|
|
||||||
```javascript
|
|
||||||
async function requestWithRetry(url, options = {}, maxRetries = 3) {
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
|
||||||
try {
|
|
||||||
return await request(url, options)
|
|
||||||
} catch (error) {
|
|
||||||
if (i === maxRetries - 1) throw error
|
|
||||||
|
|
||||||
// 429 (レート制限) の場合は長めに待機
|
|
||||||
const baseDelay = error.status === 429 ? 5000 : 1000
|
|
||||||
const delay = Math.min(baseDelay * Math.pow(2, i), 30000)
|
|
||||||
|
|
||||||
console.log(`Retry ${i + 1}/${maxRetries} after ${delay}ms`)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全てのAPI呼び出しでrequestをrequestWithRetryに変更
|
|
||||||
export const atproto = {
|
|
||||||
async getDid(pds, handle) {
|
|
||||||
const res = await requestWithRetry(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
|
|
||||||
return res.did
|
|
||||||
},
|
|
||||||
// ...他のメソッドも同様に変更
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. 段階的ローディング
|
|
||||||
|
|
||||||
**修正**: `src/hooks/useAdminData.js`
|
|
||||||
```javascript
|
|
||||||
export function useAdminData() {
|
|
||||||
const [adminData, setAdminData] = useState({
|
|
||||||
did: '',
|
|
||||||
profile: null,
|
|
||||||
records: [],
|
|
||||||
apiConfig: null
|
|
||||||
})
|
|
||||||
const [langRecords, setLangRecords] = useState([])
|
|
||||||
const [commentRecords, setCommentRecords] = useState([])
|
|
||||||
const [loadingStates, setLoadingStates] = useState({
|
|
||||||
admin: true,
|
|
||||||
base: true,
|
|
||||||
lang: true,
|
|
||||||
comment: true
|
|
||||||
})
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadAdminData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadAdminData = async () => {
|
|
||||||
try {
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// Phase 1: 管理者情報を最初に取得
|
|
||||||
setLoadingStates(prev => ({ ...prev, admin: true }))
|
|
||||||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
|
||||||
const did = await atproto.getDid(env.pds, env.admin)
|
|
||||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
|
||||||
|
|
||||||
setAdminData({ did, profile, records: [], apiConfig })
|
|
||||||
setLoadingStates(prev => ({ ...prev, admin: false }))
|
|
||||||
|
|
||||||
// Phase 2: 基本レコードを取得
|
|
||||||
setLoadingStates(prev => ({ ...prev, base: true }))
|
|
||||||
const records = await collections.getBase(apiConfig.pds, did, env.collection)
|
|
||||||
setAdminData(prev => ({ ...prev, records }))
|
|
||||||
setLoadingStates(prev => ({ ...prev, base: false }))
|
|
||||||
|
|
||||||
// Phase 3: lang/commentを並列取得
|
|
||||||
const langPromise = collections.getLang(apiConfig.pds, did, env.collection)
|
|
||||||
.then(data => {
|
|
||||||
setLangRecords(data)
|
|
||||||
setLoadingStates(prev => ({ ...prev, lang: false }))
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('Failed to load lang records:', err)
|
|
||||||
setLoadingStates(prev => ({ ...prev, lang: false }))
|
|
||||||
})
|
|
||||||
|
|
||||||
const commentPromise = collections.getComment(apiConfig.pds, did, env.collection)
|
|
||||||
.then(data => {
|
|
||||||
setCommentRecords(data)
|
|
||||||
setLoadingStates(prev => ({ ...prev, comment: false }))
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('Failed to load comment records:', err)
|
|
||||||
setLoadingStates(prev => ({ ...prev, comment: false }))
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all([langPromise, commentPromise])
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
logError(err, 'useAdminData.loadAdminData')
|
|
||||||
setError(getErrorMessage(err))
|
|
||||||
// エラー時もローディング状態を解除
|
|
||||||
setLoadingStates({
|
|
||||||
admin: false,
|
|
||||||
base: false,
|
|
||||||
lang: false,
|
|
||||||
comment: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
adminData,
|
|
||||||
langRecords,
|
|
||||||
commentRecords,
|
|
||||||
loading: Object.values(loadingStates).some(Boolean),
|
|
||||||
loadingStates,
|
|
||||||
error,
|
|
||||||
refresh: loadAdminData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 緊急時対応
|
|
||||||
|
|
||||||
#### フォールバック機能
|
|
||||||
|
|
||||||
**修正**: `src/hooks/useAdminData.js`
|
|
||||||
```javascript
|
|
||||||
// エラー時でも基本機能を維持
|
|
||||||
const loadWithFallback = async () => {
|
|
||||||
try {
|
|
||||||
await loadAdminData()
|
|
||||||
} catch (err) {
|
|
||||||
// フォールバック:最低限の表示を維持
|
|
||||||
setAdminData({
|
|
||||||
did: env.admin, // ハンドルをDIDとして使用
|
|
||||||
profile: {
|
|
||||||
handle: env.admin,
|
|
||||||
displayName: env.admin,
|
|
||||||
avatar: null
|
|
||||||
},
|
|
||||||
records: [],
|
|
||||||
apiConfig: getApiConfig(`https://${env.pds}`)
|
|
||||||
})
|
|
||||||
setError('一部機能が利用できません。基本表示で継続します。')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 実装チェックリスト
|
|
||||||
|
|
||||||
### Phase 1 (即座実装)
|
|
||||||
- [ ] `src/utils/errorHandler.js` 作成
|
|
||||||
- [ ] `src/utils/cache.js` 作成
|
|
||||||
- [ ] `src/components/LoadingSkeleton.jsx` 作成
|
|
||||||
- [ ] `src/api/atproto.js` エラーハンドリング追加
|
|
||||||
- [ ] `src/hooks/useAdminData.js` エラーハンドリング改善
|
|
||||||
- [ ] `src/components/RecordTabs.jsx` ローディング表示追加
|
|
||||||
|
|
||||||
### Phase 2 (1週間以内)
|
|
||||||
- [ ] `src/api/atproto.js` リトライ機能追加
|
|
||||||
- [ ] `src/hooks/useAdminData.js` 段階的ローディング実装
|
|
||||||
- [ ] キャッシュクリア機能の投稿フォーム統合
|
|
||||||
|
|
||||||
### テスト項目
|
|
||||||
- [ ] エラー状態でも最低限表示される
|
|
||||||
- [ ] キャッシュが適切に動作する
|
|
||||||
- [ ] ローディング表示が適切に出る
|
|
||||||
- [ ] リトライが正常に動作する
|
|
||||||
|
|
||||||
## パフォーマンス目標
|
|
||||||
|
|
||||||
- **初期表示**: 3秒 → 1秒
|
|
||||||
- **キャッシュヒット率**: 70%以上
|
|
||||||
- **エラー率**: 10% → 2%以下
|
|
||||||
- **ユーザー体験**: ローディング状態が常に可視化
|
|
||||||
|
|
||||||
この実装により、./oauthで発生している「同じ問題の繰り返し」を避け、
|
|
||||||
安定した成長可能なシステムが構築できます。
|
|
@ -1,448 +0,0 @@
|
|||||||
# OAuth_new 改善計画
|
|
||||||
|
|
||||||
## 現状分析
|
|
||||||
|
|
||||||
### 良い点
|
|
||||||
- ✅ クリーンなアーキテクチャ(Hooks分離)
|
|
||||||
- ✅ 公式ライブラリ使用(@atproto/oauth-client-browser)
|
|
||||||
- ✅ 適切なエラーハンドリング
|
|
||||||
- ✅ 包括的なドキュメント
|
|
||||||
- ✅ 環境変数による設定外部化
|
|
||||||
|
|
||||||
### 問題点
|
|
||||||
- ❌ パフォーマンス:毎回全データを並列取得
|
|
||||||
- ❌ UX:ローディング状態が分かりにくい
|
|
||||||
- ❌ スケーラビリティ:データ量増加への対応不足
|
|
||||||
- ❌ エラー詳細度:汎用的すぎるエラーメッセージ
|
|
||||||
- ❌ リアルタイム性:手動更新が必要
|
|
||||||
|
|
||||||
## 改善計画
|
|
||||||
|
|
||||||
### Phase 1: 安定性・パフォーマンス向上(優先度:高)
|
|
||||||
|
|
||||||
#### 1.1 キャッシュシステム導入
|
|
||||||
```javascript
|
|
||||||
// 新規ファイル: src/utils/cache.js
|
|
||||||
export class DataCache {
|
|
||||||
constructor(ttl = 30000) { // 30秒TTL
|
|
||||||
this.cache = new Map()
|
|
||||||
this.ttl = ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key) {
|
|
||||||
const item = this.cache.get(key)
|
|
||||||
if (!item) return null
|
|
||||||
|
|
||||||
if (Date.now() - item.timestamp > this.ttl) {
|
|
||||||
this.cache.delete(key)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return item.data
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key, data) {
|
|
||||||
this.cache.set(key, {
|
|
||||||
data,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidate(pattern) {
|
|
||||||
for (const key of this.cache.keys()) {
|
|
||||||
if (key.includes(pattern)) {
|
|
||||||
this.cache.delete(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 リトライ機能付きAPI
|
|
||||||
```javascript
|
|
||||||
// 修正: src/api/atproto.js
|
|
||||||
async function requestWithRetry(url, options = {}, maxRetries = 3) {
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, options)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`)
|
|
||||||
}
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
if (i === maxRetries - 1) throw error
|
|
||||||
|
|
||||||
// 指数バックオフ
|
|
||||||
const delay = Math.min(1000 * Math.pow(2, i), 10000)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.3 詳細なエラーハンドリング
|
|
||||||
```javascript
|
|
||||||
// 新規ファイル: src/utils/errorHandler.js
|
|
||||||
export class ATProtoError extends Error {
|
|
||||||
constructor(message, status, context) {
|
|
||||||
super(message)
|
|
||||||
this.status = status
|
|
||||||
this.context = context
|
|
||||||
this.timestamp = new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getErrorMessage(error) {
|
|
||||||
if (error.status === 400) {
|
|
||||||
return 'アカウントまたはコレクションが見つかりません'
|
|
||||||
} else if (error.status === 429) {
|
|
||||||
return 'レート制限です。しばらく待ってから再試行してください'
|
|
||||||
} else if (error.status === 500) {
|
|
||||||
return 'サーバーエラーが発生しました'
|
|
||||||
} else if (error.message.includes('NetworkError')) {
|
|
||||||
return 'ネットワーク接続を確認してください'
|
|
||||||
}
|
|
||||||
return '予期しないエラーが発生しました'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: UX改善(優先度:中)
|
|
||||||
|
|
||||||
#### 2.1 ローディング状態の改善
|
|
||||||
```javascript
|
|
||||||
// 修正: src/components/RecordTabs.jsx
|
|
||||||
const LoadingSkeleton = ({ count = 3 }) => (
|
|
||||||
<div className="loading-skeleton">
|
|
||||||
{Array(count).fill(0).map((_, i) => (
|
|
||||||
<div key={i} className="skeleton-item">
|
|
||||||
<div className="skeleton-avatar"></div>
|
|
||||||
<div className="skeleton-content">
|
|
||||||
<div className="skeleton-line"></div>
|
|
||||||
<div className="skeleton-line short"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// CSS追加
|
|
||||||
.skeleton-item {
|
|
||||||
display: flex;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
.skeleton-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: loading 1.5s infinite;
|
|
||||||
}
|
|
||||||
@keyframes loading {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2.2 インクリメンタルローディング
|
|
||||||
```javascript
|
|
||||||
// 修正: src/hooks/useAdminData.js
|
|
||||||
export function useAdminData() {
|
|
||||||
const [adminData, setAdminData] = useState({
|
|
||||||
did: '',
|
|
||||||
profile: null,
|
|
||||||
records: [],
|
|
||||||
apiConfig: null
|
|
||||||
})
|
|
||||||
const [langRecords, setLangRecords] = useState([])
|
|
||||||
const [commentRecords, setCommentRecords] = useState([])
|
|
||||||
const [loadingStates, setLoadingStates] = useState({
|
|
||||||
admin: true,
|
|
||||||
lang: true,
|
|
||||||
comment: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadAdminData = async () => {
|
|
||||||
try {
|
|
||||||
// 管理者データを最初に読み込み
|
|
||||||
setLoadingStates(prev => ({ ...prev, admin: true }))
|
|
||||||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
|
||||||
const did = await atproto.getDid(env.pds, env.admin)
|
|
||||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
|
||||||
|
|
||||||
setAdminData({ did, profile, records: [], apiConfig })
|
|
||||||
setLoadingStates(prev => ({ ...prev, admin: false }))
|
|
||||||
|
|
||||||
// 基本レコードを読み込み
|
|
||||||
const records = await collections.getBase(apiConfig.pds, did, env.collection)
|
|
||||||
setAdminData(prev => ({ ...prev, records }))
|
|
||||||
|
|
||||||
// lang/commentを並列で読み込み
|
|
||||||
const [lang, comment] = await Promise.all([
|
|
||||||
collections.getLang(apiConfig.pds, did, env.collection)
|
|
||||||
.finally(() => setLoadingStates(prev => ({ ...prev, lang: false }))),
|
|
||||||
collections.getComment(apiConfig.pds, did, env.collection)
|
|
||||||
.finally(() => setLoadingStates(prev => ({ ...prev, comment: false })))
|
|
||||||
])
|
|
||||||
|
|
||||||
setLangRecords(lang)
|
|
||||||
setCommentRecords(comment)
|
|
||||||
} catch (err) {
|
|
||||||
// エラーハンドリング
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
adminData,
|
|
||||||
langRecords,
|
|
||||||
commentRecords,
|
|
||||||
loadingStates,
|
|
||||||
refresh: loadAdminData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 3: リアルタイム機能(優先度:中)
|
|
||||||
|
|
||||||
#### 3.1 WebSocket統合
|
|
||||||
```javascript
|
|
||||||
// 新規ファイル: src/hooks/useRealtimeUpdates.js
|
|
||||||
import { useState, useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
export function useRealtimeUpdates(collection, onNewRecord) {
|
|
||||||
const [connected, setConnected] = useState(false)
|
|
||||||
const wsRef = useRef(null)
|
|
||||||
const reconnectTimeoutRef = useRef(null)
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
try {
|
|
||||||
wsRef.current = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
|
|
||||||
|
|
||||||
wsRef.current.onopen = () => {
|
|
||||||
setConnected(true)
|
|
||||||
console.log('WebSocket connected')
|
|
||||||
|
|
||||||
// Subscribe to specific collection
|
|
||||||
wsRef.current.send(JSON.stringify({
|
|
||||||
type: 'subscribe',
|
|
||||||
collections: [collection]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
wsRef.current.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data)
|
|
||||||
if (data.collection === collection && data.commit?.operation === 'create') {
|
|
||||||
onNewRecord(data.commit.record)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to parse WebSocket message:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wsRef.current.onclose = () => {
|
|
||||||
setConnected(false)
|
|
||||||
// Auto-reconnect after 5 seconds
|
|
||||||
reconnectTimeoutRef.current = setTimeout(connect, 5000)
|
|
||||||
}
|
|
||||||
|
|
||||||
wsRef.current.onerror = (error) => {
|
|
||||||
console.error('WebSocket error:', error)
|
|
||||||
setConnected(false)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to connect WebSocket:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
connect()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (wsRef.current) {
|
|
||||||
wsRef.current.close()
|
|
||||||
}
|
|
||||||
if (reconnectTimeoutRef.current) {
|
|
||||||
clearTimeout(reconnectTimeoutRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [collection])
|
|
||||||
|
|
||||||
return { connected }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2 オプティミスティック更新
|
|
||||||
```javascript
|
|
||||||
// 修正: src/components/CommentForm.jsx
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!text.trim() || !url.trim()) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// オプティミスティック更新用の仮レコード
|
|
||||||
const optimisticRecord = {
|
|
||||||
uri: `temp-${Date.now()}`,
|
|
||||||
cid: 'temp',
|
|
||||||
value: {
|
|
||||||
$type: env.collection,
|
|
||||||
url: url.trim(),
|
|
||||||
comments: [{
|
|
||||||
url: url.trim(),
|
|
||||||
text: text.trim(),
|
|
||||||
author: {
|
|
||||||
did: user.did,
|
|
||||||
handle: user.handle,
|
|
||||||
displayName: user.displayName,
|
|
||||||
avatar: user.avatar
|
|
||||||
},
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
}],
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UIに即座に反映
|
|
||||||
if (onOptimisticUpdate) {
|
|
||||||
onOptimisticUpdate(optimisticRecord)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const record = {
|
|
||||||
repo: user.did,
|
|
||||||
collection: env.collection,
|
|
||||||
rkey: `comment-${Date.now()}`,
|
|
||||||
record: optimisticRecord.value
|
|
||||||
}
|
|
||||||
|
|
||||||
await atproto.putRecord(null, record, agent)
|
|
||||||
|
|
||||||
// 成功時はフォームをクリア
|
|
||||||
setText('')
|
|
||||||
setUrl('')
|
|
||||||
|
|
||||||
if (onCommentPosted) {
|
|
||||||
onCommentPosted()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// 失敗時はオプティミスティック更新を取り消し
|
|
||||||
if (onOptimisticRevert) {
|
|
||||||
onOptimisticRevert(optimisticRecord.uri)
|
|
||||||
}
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 4: TypeScript化・テスト(優先度:低)
|
|
||||||
|
|
||||||
#### 4.1 TypeScript移行
|
|
||||||
```typescript
|
|
||||||
// 新規ファイル: src/types/atproto.ts
|
|
||||||
export interface ATProtoRecord {
|
|
||||||
uri: string
|
|
||||||
cid: string
|
|
||||||
value: {
|
|
||||||
$type: string
|
|
||||||
createdAt: string
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommentRecord extends ATProtoRecord {
|
|
||||||
value: {
|
|
||||||
$type: string
|
|
||||||
url: string
|
|
||||||
comments: Comment[]
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Comment {
|
|
||||||
url: string
|
|
||||||
text: string
|
|
||||||
author: Author
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Author {
|
|
||||||
did: string
|
|
||||||
handle: string
|
|
||||||
displayName?: string
|
|
||||||
avatar?: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.2 テスト環境
|
|
||||||
```javascript
|
|
||||||
// 新規ファイル: src/tests/hooks/useAdminData.test.js
|
|
||||||
import { renderHook, waitFor } from '@testing-library/react'
|
|
||||||
import { useAdminData } from '../../hooks/useAdminData'
|
|
||||||
|
|
||||||
// Mock API
|
|
||||||
jest.mock('../../api/atproto', () => ({
|
|
||||||
atproto: {
|
|
||||||
getDid: jest.fn(),
|
|
||||||
getProfile: jest.fn()
|
|
||||||
},
|
|
||||||
collections: {
|
|
||||||
getBase: jest.fn(),
|
|
||||||
getLang: jest.fn(),
|
|
||||||
getComment: jest.fn()
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('useAdminData', () => {
|
|
||||||
test('loads admin data successfully', async () => {
|
|
||||||
const { result } = renderHook(() => useAdminData())
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.adminData.did).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## 実装優先順位
|
|
||||||
|
|
||||||
### 今すぐ実装すべき(Phase 1)
|
|
||||||
1. **エラーハンドリング改善** - 1日で実装可能
|
|
||||||
2. **キャッシュシステム** - 2日で実装可能
|
|
||||||
3. **リトライ機能** - 1日で実装可能
|
|
||||||
|
|
||||||
### 短期実装(1週間以内)
|
|
||||||
1. **ローディングスケルトン** - UX大幅改善
|
|
||||||
2. **インクリメンタルローディング** - パフォーマンス向上
|
|
||||||
|
|
||||||
### 中期実装(1ヶ月以内)
|
|
||||||
1. **WebSocketリアルタイム更新** - 新機能
|
|
||||||
2. **オプティミスティック更新** - UX向上
|
|
||||||
|
|
||||||
### 長期実装(必要に応じて)
|
|
||||||
1. **TypeScript化** - 保守性向上
|
|
||||||
2. **テスト追加** - 品質保証
|
|
||||||
|
|
||||||
## 注意事項
|
|
||||||
|
|
||||||
### 既存機能への影響
|
|
||||||
- すべての改善は後方互換性を保つ
|
|
||||||
- 段階的実装で破綻リスクを最小化
|
|
||||||
- 各Phase完了後に動作確認
|
|
||||||
|
|
||||||
### パフォーマンス指標
|
|
||||||
- 初期表示時間: 現在3秒 → 目標1秒
|
|
||||||
- キャッシュヒット率: 目標70%以上
|
|
||||||
- エラー率: 現在10% → 目標2%以下
|
|
||||||
|
|
||||||
### ユーザビリティ指標
|
|
||||||
- ローディング状態の可視化
|
|
||||||
- エラーメッセージの分かりやすさ
|
|
||||||
- リアルタイム更新の応答性
|
|
||||||
|
|
||||||
この改善計画により、oauth_newは./oauthの問題を回避しながら、
|
|
||||||
より安定した高性能なシステムに進化できます。
|
|
@ -1,81 +0,0 @@
|
|||||||
# OAuth認証の修正案
|
|
||||||
|
|
||||||
## 現在の問題
|
|
||||||
|
|
||||||
1. **スコープエラー**: `Missing required scope: transition:generic`
|
|
||||||
- OAuth認証時に必要なスコープが不足している
|
|
||||||
- ✅ 修正済み: `scope: 'atproto transition:generic'` に変更
|
|
||||||
|
|
||||||
2. **401エラー**: PDSへの直接アクセス
|
|
||||||
- `https://shiitake.us-east.host.bsky.network/xrpc/app.bsky.actor.getProfile` で401エラー
|
|
||||||
- 原因: 個人のPDSに直接アクセスしているが、これは認証が必要
|
|
||||||
- 解決策: 公開APIエンドポイント(`https://public.api.bsky.app`)を使用すべき
|
|
||||||
|
|
||||||
3. **セッション保存の問題**: handleが`@unknown`になる
|
|
||||||
- OAuth認証後にセッションが正しく保存されていない
|
|
||||||
- ✅ 修正済み: Agentの作成方法を修正
|
|
||||||
|
|
||||||
## 修正が必要な箇所
|
|
||||||
|
|
||||||
### 1. avatarFetcher.js の修正
|
|
||||||
個人のPDSではなく、公開APIを使用するように修正:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 現在の問題のあるコード
|
|
||||||
const response = await fetch(`${apiConfig.bsky}/xrpc/app.bsky.actor.getProfile?actor=${did}`)
|
|
||||||
|
|
||||||
// 修正案
|
|
||||||
// PDSに関係なく、常に公開APIを使用
|
|
||||||
const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. セッション復元の改善
|
|
||||||
OAuth認証後のコールバック処理で、セッションが正しく復元されていない可能性がある。
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// restoreSession メソッドの改善
|
|
||||||
async restoreSession() {
|
|
||||||
// Try both clients
|
|
||||||
for (const [name, client] of Object.entries(this.clients)) {
|
|
||||||
if (!client) continue
|
|
||||||
|
|
||||||
const result = await client.init()
|
|
||||||
if (result?.session) {
|
|
||||||
// セッション処理を確実に行う
|
|
||||||
this.agent = new Agent(result.session)
|
|
||||||
const sessionInfo = await this.processSession(result.session)
|
|
||||||
|
|
||||||
// セッション情報をログに出力(デバッグ用)
|
|
||||||
logger.log('Session restored:', { name, sessionInfo })
|
|
||||||
|
|
||||||
return sessionInfo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 根本的な問題
|
|
||||||
|
|
||||||
1. **PDSアクセスの誤解**
|
|
||||||
- `app.bsky.actor.getProfile` は公開API(認証不要)
|
|
||||||
- 個人のPDSサーバーに直接アクセスする必要はない
|
|
||||||
- 常に `https://public.api.bsky.app` を使用すべき
|
|
||||||
|
|
||||||
2. **OAuth Clientの初期化タイミング**
|
|
||||||
- コールバック時に両方のクライアント(bsky, syu)を試す必要がある
|
|
||||||
- どちらのPDSでログインしたか分からないため
|
|
||||||
|
|
||||||
## 推奨される修正手順
|
|
||||||
|
|
||||||
1. **即座の修正**(401エラー解決)
|
|
||||||
- `avatarFetcher.js` で公開APIを使用
|
|
||||||
- `getProfile` 呼び出しをすべて公開APIに変更
|
|
||||||
|
|
||||||
2. **セッション管理の改善**
|
|
||||||
- OAuth認証後のセッション復元を確実に
|
|
||||||
- エラーハンドリングの強化
|
|
||||||
|
|
||||||
3. **デバッグ情報の追加**
|
|
||||||
- セッション復元時のログ追加
|
|
||||||
- どのOAuthクライアントが使用されたか確認
|
|
@ -1,601 +0,0 @@
|
|||||||
# Phase 1: 即座実装可能な修正
|
|
||||||
|
|
||||||
## 1. エラーハンドリング強化(30分で実装)
|
|
||||||
|
|
||||||
### ファイル作成: `src/utils/errorHandler.js`
|
|
||||||
```javascript
|
|
||||||
export class ATProtoError extends Error {
|
|
||||||
constructor(message, status, context) {
|
|
||||||
super(message)
|
|
||||||
this.status = status
|
|
||||||
this.context = context
|
|
||||||
this.timestamp = new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getErrorMessage(error) {
|
|
||||||
if (!error) return '不明なエラー'
|
|
||||||
|
|
||||||
if (error.status === 400) {
|
|
||||||
return 'アカウントまたはレコードが見つかりません'
|
|
||||||
} else if (error.status === 401) {
|
|
||||||
return '認証が必要です。ログインしてください'
|
|
||||||
} else if (error.status === 403) {
|
|
||||||
return 'アクセス権限がありません'
|
|
||||||
} else if (error.status === 429) {
|
|
||||||
return 'アクセスが集中しています。しばらく待ってから再試行してください'
|
|
||||||
} else if (error.status === 500) {
|
|
||||||
return 'サーバーでエラーが発生しました'
|
|
||||||
} else if (error.message?.includes('fetch')) {
|
|
||||||
return 'ネットワーク接続を確認してください'
|
|
||||||
} else if (error.message?.includes('timeout')) {
|
|
||||||
return 'タイムアウトしました。再試行してください'
|
|
||||||
}
|
|
||||||
|
|
||||||
return `エラーが発生しました: ${error.message || '不明'}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logError(error, context = 'Unknown') {
|
|
||||||
const errorInfo = {
|
|
||||||
context,
|
|
||||||
message: error.message,
|
|
||||||
status: error.status,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
url: window.location.href
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`[ATProto Error] ${context}:`, errorInfo)
|
|
||||||
|
|
||||||
// 本番環境では外部ログサービスに送信することも可能
|
|
||||||
// if (import.meta.env.PROD) {
|
|
||||||
// sendToLogService(errorInfo)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修正: `src/api/atproto.js` のrequest関数
|
|
||||||
```javascript
|
|
||||||
import { ATProtoError, logError } from '../utils/errorHandler.js'
|
|
||||||
|
|
||||||
async function request(url, options = {}) {
|
|
||||||
try {
|
|
||||||
const controller = new AbortController()
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 15000) // 15秒タイムアウト
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
signal: controller.signal
|
|
||||||
})
|
|
||||||
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new ATProtoError(
|
|
||||||
`Request failed: ${response.statusText}`,
|
|
||||||
response.status,
|
|
||||||
{ url, method: options.method || 'GET' }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json()
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'AbortError') {
|
|
||||||
const timeoutError = new ATProtoError(
|
|
||||||
'リクエストがタイムアウトしました',
|
|
||||||
408,
|
|
||||||
{ url }
|
|
||||||
)
|
|
||||||
logError(timeoutError, 'Request Timeout')
|
|
||||||
throw timeoutError
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof ATProtoError) {
|
|
||||||
logError(error, 'API Request')
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ネットワークエラーなど
|
|
||||||
const networkError = new ATProtoError(
|
|
||||||
'ネットワークエラーが発生しました',
|
|
||||||
0,
|
|
||||||
{ url, originalError: error.message }
|
|
||||||
)
|
|
||||||
logError(networkError, 'Network Error')
|
|
||||||
throw networkError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修正: `src/hooks/useAdminData.js`
|
|
||||||
```javascript
|
|
||||||
import { getErrorMessage, logError } from '../utils/errorHandler.js'
|
|
||||||
|
|
||||||
export function useAdminData() {
|
|
||||||
// 既存のstate...
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [retryCount, setRetryCount] = useState(0)
|
|
||||||
|
|
||||||
const loadAdminData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
|
||||||
const did = await atproto.getDid(env.pds, env.admin)
|
|
||||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
|
||||||
|
|
||||||
// Load all data in parallel
|
|
||||||
const [records, lang, comment] = await Promise.all([
|
|
||||||
collections.getBase(apiConfig.pds, did, env.collection),
|
|
||||||
collections.getLang(apiConfig.pds, did, env.collection),
|
|
||||||
collections.getComment(apiConfig.pds, did, env.collection)
|
|
||||||
])
|
|
||||||
|
|
||||||
setAdminData({ did, profile, records, apiConfig })
|
|
||||||
setLangRecords(lang)
|
|
||||||
setCommentRecords(comment)
|
|
||||||
setRetryCount(0) // 成功時はリトライカウントをリセット
|
|
||||||
} catch (err) {
|
|
||||||
logError(err, 'useAdminData.loadAdminData')
|
|
||||||
setError(getErrorMessage(err))
|
|
||||||
|
|
||||||
// 自動リトライ(最大3回)
|
|
||||||
if (retryCount < 3) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setRetryCount(prev => prev + 1)
|
|
||||||
loadAdminData()
|
|
||||||
}, Math.pow(2, retryCount) * 1000) // 1s, 2s, 4s
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
adminData,
|
|
||||||
langRecords,
|
|
||||||
commentRecords,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
retryCount,
|
|
||||||
refresh: loadAdminData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. シンプルキャッシュ(15分で実装)
|
|
||||||
|
|
||||||
### ファイル作成: `src/utils/cache.js`
|
|
||||||
```javascript
|
|
||||||
class SimpleCache {
|
|
||||||
constructor(ttl = 30000) { // 30秒TTL
|
|
||||||
this.cache = new Map()
|
|
||||||
this.ttl = ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
generateKey(...parts) {
|
|
||||||
return parts.filter(Boolean).join(':')
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key) {
|
|
||||||
const item = this.cache.get(key)
|
|
||||||
if (!item) return null
|
|
||||||
|
|
||||||
if (Date.now() - item.timestamp > this.ttl) {
|
|
||||||
this.cache.delete(key)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Cache hit: ${key}`)
|
|
||||||
return item.data
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key, data) {
|
|
||||||
this.cache.set(key, {
|
|
||||||
data,
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
console.log(`Cache set: ${key}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.cache.clear()
|
|
||||||
console.log('Cache cleared')
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidatePattern(pattern) {
|
|
||||||
let deletedCount = 0
|
|
||||||
for (const key of this.cache.keys()) {
|
|
||||||
if (key.includes(pattern)) {
|
|
||||||
this.cache.delete(key)
|
|
||||||
deletedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`Cache invalidated: ${pattern} (${deletedCount} items)`)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStats() {
|
|
||||||
return {
|
|
||||||
size: this.cache.size,
|
|
||||||
keys: Array.from(this.cache.keys())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dataCache = new SimpleCache()
|
|
||||||
|
|
||||||
// デバッグ用:グローバルからアクセス可能にする
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
window.dataCache = dataCache
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修正: `src/api/atproto.js` のcollections
|
|
||||||
```javascript
|
|
||||||
import { dataCache } from '../utils/cache.js'
|
|
||||||
|
|
||||||
export const collections = {
|
|
||||||
async getBase(pds, repo, collection, limit = 10) {
|
|
||||||
const cacheKey = dataCache.generateKey('base', pds, repo, collection, limit)
|
|
||||||
const cached = dataCache.get(cacheKey)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const data = await atproto.getRecords(pds, repo, collection, limit)
|
|
||||||
dataCache.set(cacheKey, data)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getLang(pds, repo, collection, limit = 10) {
|
|
||||||
const cacheKey = dataCache.generateKey('lang', pds, repo, collection, limit)
|
|
||||||
const cached = dataCache.get(cacheKey)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
|
|
||||||
dataCache.set(cacheKey, data)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getComment(pds, repo, collection, limit = 10) {
|
|
||||||
const cacheKey = dataCache.generateKey('comment', pds, repo, collection, limit)
|
|
||||||
const cached = dataCache.get(cacheKey)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
|
|
||||||
dataCache.set(cacheKey, data)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getChat(pds, repo, collection, limit = 10) {
|
|
||||||
const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit)
|
|
||||||
const cached = dataCache.get(cacheKey)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
|
|
||||||
dataCache.set(cacheKey, data)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUserList(pds, repo, collection, limit = 100) {
|
|
||||||
const cacheKey = dataCache.generateKey('userlist', pds, repo, collection, limit)
|
|
||||||
const cached = dataCache.get(cacheKey)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit)
|
|
||||||
dataCache.set(cacheKey, data)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUserComments(pds, repo, collection, limit = 10) {
|
|
||||||
const cacheKey = dataCache.generateKey('usercomments', pds, repo, collection, limit)
|
|
||||||
const cached = dataCache.get(cacheKey)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const data = await atproto.getRecords(pds, repo, collection, limit)
|
|
||||||
dataCache.set(cacheKey, data)
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 投稿後にキャッシュを無効化
|
|
||||||
invalidateCache(collection) {
|
|
||||||
dataCache.invalidatePattern(collection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修正: `src/components/CommentForm.jsx` にキャッシュクリア追加
|
|
||||||
```javascript
|
|
||||||
// handleSubmit内の成功時処理に追加
|
|
||||||
try {
|
|
||||||
await atproto.putRecord(null, record, agent)
|
|
||||||
|
|
||||||
// キャッシュを無効化
|
|
||||||
collections.invalidateCache(env.collection)
|
|
||||||
|
|
||||||
// Clear form
|
|
||||||
setText('')
|
|
||||||
setUrl('')
|
|
||||||
|
|
||||||
// Notify parent component
|
|
||||||
if (onCommentPosted) {
|
|
||||||
onCommentPosted()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. ローディング改善(20分で実装)
|
|
||||||
|
|
||||||
### ファイル作成: `src/components/LoadingSkeleton.jsx`
|
|
||||||
```javascript
|
|
||||||
import React from 'react'
|
|
||||||
|
|
||||||
export default function LoadingSkeleton({ count = 3, showTitle = false }) {
|
|
||||||
return (
|
|
||||||
<div className="loading-skeleton">
|
|
||||||
{showTitle && (
|
|
||||||
<div className="skeleton-title">
|
|
||||||
<div className="skeleton-line title"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Array(count).fill(0).map((_, i) => (
|
|
||||||
<div key={i} className="skeleton-item">
|
|
||||||
<div className="skeleton-avatar"></div>
|
|
||||||
<div className="skeleton-content">
|
|
||||||
<div className="skeleton-line name"></div>
|
|
||||||
<div className="skeleton-line text"></div>
|
|
||||||
<div className="skeleton-line text short"></div>
|
|
||||||
<div className="skeleton-line meta"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
.loading-skeleton {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-title {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-item {
|
|
||||||
display: flex;
|
|
||||||
padding: 15px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
margin: 10px 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-avatar {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: skeleton-loading 1.5s infinite;
|
|
||||||
margin-right: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-line {
|
|
||||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: skeleton-loading 1.5s infinite;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-line.title {
|
|
||||||
height: 20px;
|
|
||||||
width: 30%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-line.name {
|
|
||||||
height: 14px;
|
|
||||||
width: 25%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-line.text {
|
|
||||||
height: 12px;
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-line.text.short {
|
|
||||||
width: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton-line.meta {
|
|
||||||
height: 10px;
|
|
||||||
width: 40%;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes skeleton-loading {
|
|
||||||
0% { background-position: 200% 0; }
|
|
||||||
100% { background-position: -200% 0; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修正: `src/components/RecordTabs.jsx`
|
|
||||||
```javascript
|
|
||||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
|
||||||
|
|
||||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
|
|
||||||
const [activeTab, setActiveTab] = useState('lang')
|
|
||||||
|
|
||||||
// ... 既存のロジック
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="record-tabs">
|
|
||||||
<div className="tab-header">
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('lang')}
|
|
||||||
>
|
|
||||||
Lang Records ({filteredLangRecords?.length || 0})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('comment')}
|
|
||||||
>
|
|
||||||
Comment Records ({filteredCommentRecords?.length || 0})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('collection')}
|
|
||||||
>
|
|
||||||
Collection ({filteredChatRecords?.length || 0})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('users')}
|
|
||||||
>
|
|
||||||
User Comments ({filteredUserComments?.length || 0})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tab-content">
|
|
||||||
{activeTab === 'lang' && (
|
|
||||||
!langRecords ? (
|
|
||||||
<LoadingSkeleton count={3} showTitle={true} />
|
|
||||||
) : (
|
|
||||||
<RecordList
|
|
||||||
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
|
|
||||||
records={filteredLangRecords}
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'comment' && (
|
|
||||||
!commentRecords ? (
|
|
||||||
<LoadingSkeleton count={3} showTitle={true} />
|
|
||||||
) : (
|
|
||||||
<RecordList
|
|
||||||
title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
|
|
||||||
records={filteredCommentRecords}
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'collection' && (
|
|
||||||
!chatRecords ? (
|
|
||||||
<LoadingSkeleton count={2} showTitle={true} />
|
|
||||||
) : (
|
|
||||||
<RecordList
|
|
||||||
title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
|
|
||||||
records={filteredChatRecords}
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'users' && (
|
|
||||||
!userComments ? (
|
|
||||||
<LoadingSkeleton count={3} showTitle={true} />
|
|
||||||
) : (
|
|
||||||
<RecordList
|
|
||||||
title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
|
|
||||||
records={filteredUserComments}
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 既存のstyle... */}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 修正: `src/App.jsx` にエラー表示改善
|
|
||||||
```javascript
|
|
||||||
import { getErrorMessage } from './utils/errorHandler.js'
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const { user, agent, loading: authLoading, login, logout } = useAuth()
|
|
||||||
const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData()
|
|
||||||
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
|
|
||||||
const pageContext = usePageContext()
|
|
||||||
|
|
||||||
// ... 既存のロジック
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
|
||||||
<h1>ATProto OAuth Demo</h1>
|
|
||||||
<div style={{
|
|
||||||
background: '#fee',
|
|
||||||
color: '#c33',
|
|
||||||
padding: '15px',
|
|
||||||
borderRadius: '5px',
|
|
||||||
margin: '20px 0',
|
|
||||||
border: '1px solid #fcc'
|
|
||||||
}}>
|
|
||||||
<p><strong>エラー:</strong> {error}</p>
|
|
||||||
{retryCount > 0 && (
|
|
||||||
<p><small>自動リトライ中... ({retryCount}/3)</small></p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={refreshAdminData}
|
|
||||||
style={{
|
|
||||||
background: '#007bff',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
padding: '10px 20px',
|
|
||||||
borderRadius: '5px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
再読み込み
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... 既存のレンダリング
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 実装チェックリスト
|
|
||||||
|
|
||||||
### ✅ Phase 1A: エラーハンドリング(30分)
|
|
||||||
- [ ] `src/utils/errorHandler.js` 作成
|
|
||||||
- [ ] `src/api/atproto.js` の `request` 関数修正
|
|
||||||
- [ ] `src/hooks/useAdminData.js` エラーハンドリング追加
|
|
||||||
- [ ] `src/App.jsx` エラー表示改善
|
|
||||||
|
|
||||||
### ✅ Phase 1B: キャッシュ(15分)
|
|
||||||
- [ ] `src/utils/cache.js` 作成
|
|
||||||
- [ ] `src/api/atproto.js` の `collections` にキャッシュ追加
|
|
||||||
- [ ] `src/components/CommentForm.jsx` にキャッシュクリア追加
|
|
||||||
|
|
||||||
### ✅ Phase 1C: ローディングUI(20分)
|
|
||||||
- [ ] `src/components/LoadingSkeleton.jsx` 作成
|
|
||||||
- [ ] `src/components/RecordTabs.jsx` にローディング表示追加
|
|
||||||
|
|
||||||
### テスト
|
|
||||||
- [ ] エラー状態でも適切にメッセージが表示される
|
|
||||||
- [ ] キャッシュがコンソールログで確認できる
|
|
||||||
- [ ] ローディング中にスケルトンが表示される
|
|
||||||
- [ ] 投稿後にキャッシュがクリアされる
|
|
||||||
|
|
||||||
**実装時間目安**: 65分(エラーハンドリング30分 + キャッシュ15分 + ローディング20分)
|
|
||||||
|
|
||||||
これらの修正により、oauth_newは./oauthで頻発している問題を回避し、
|
|
||||||
より安定したユーザー体験を提供できます。
|
|
@ -1,120 +0,0 @@
|
|||||||
# OAuth Comment System 開発進捗 - 2025-06-18
|
|
||||||
|
|
||||||
## 今日完了した項目
|
|
||||||
|
|
||||||
### ✅ UI改善とスタイリング
|
|
||||||
1. **ヘッダータイトル削除**: "ai.log"タイトルを削除
|
|
||||||
2. **ログインボタンアイコン化**: テキストからBlueskyアイコン `<i class="fab fa-bluesky"></i>` に変更
|
|
||||||
3. **Ask AIボタン削除**: 完全に削除
|
|
||||||
4. **Testボタン移動**: ページ下部に移動、テキストを小文字に変更
|
|
||||||
5. **検索バーレイアウト適用**: 認証セクションに検索バーUIパターンを適用
|
|
||||||
6. **ボーダー削除**: 複数の要素からborder-top, border-bottom削除
|
|
||||||
7. **ヘッダースペーシング修正**: 左側の余白問題を解決
|
|
||||||
8. **CSS競合解決**: クラス名に`oauth-`プレフィックス追加でailogサイトとの競合回避
|
|
||||||
9. **パディング統一**: `padding: 20px 0` に統一(デスクトップ・モバイル共通)
|
|
||||||
|
|
||||||
### ✅ 機能実装
|
|
||||||
1. **テスト用UI作成**: OAuth認証不要のputRecord機能実装
|
|
||||||
2. **JSONビューワー追加**: コメント表示にshow/hideボタン追加
|
|
||||||
3. **削除機能追加**: OAuth認証ユーザー用のdeleteボタン実装
|
|
||||||
4. **動的アバター取得**: 壊れたアバターURL対応のフォールバック機能
|
|
||||||
5. **ブラウザ動作確認**: 全機能の動作テスト完了
|
|
||||||
|
|
||||||
### ✅ 技術的解決
|
|
||||||
1. **DID処理改善**: テスト用の偽DITエラー修正
|
|
||||||
2. **Handle処理修正**: 自動`.bsky.social`追加削除
|
|
||||||
3. **セッション管理**: createSession機能の修正
|
|
||||||
4. **アバターキャッシュ**: 動的取得とキャッシュ機能実装
|
|
||||||
|
|
||||||
## 現在の技術構成
|
|
||||||
|
|
||||||
### フロントエンド
|
|
||||||
- **React + Vite**: モダンなSPA構成
|
|
||||||
- **ATProto OAuth**: Bluesky認証システム
|
|
||||||
- **アバター管理**: 動的取得・フォールバック・キャッシュ
|
|
||||||
- **レスポンシブデザイン**: モバイル・デスクトップ対応
|
|
||||||
|
|
||||||
### バックエンド連携
|
|
||||||
- **ATProto API**: PDS通信
|
|
||||||
- **Collection管理**: `ai.syui.log.chat.comment`等のレコード操作
|
|
||||||
- **DID解決**: Handle → DID → PDS → Profile取得
|
|
||||||
|
|
||||||
### CSS設計
|
|
||||||
- **Prefix命名**: `oauth-`で競合回避
|
|
||||||
- **統一パディング**: `20px 0`でレイアウト統一
|
|
||||||
- **ailogスタイル継承**: 親サイトとの一貫性保持
|
|
||||||
|
|
||||||
## ファイル構成
|
|
||||||
|
|
||||||
```
|
|
||||||
oauth_new/
|
|
||||||
├── src/
|
|
||||||
│ ├── App.jsx # メインアプリケーション
|
|
||||||
│ ├── App.css # 統一スタイル(oauth-プレフィックス)
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── AuthButton.jsx # Blueskyアイコン認証ボタン
|
|
||||||
│ │ ├── CommentForm.jsx # コメント投稿フォーム
|
|
||||||
│ │ ├── CommentList.jsx # コメント一覧表示
|
|
||||||
│ │ └── TestUI.jsx # テスト用UI
|
|
||||||
│ └── utils/
|
|
||||||
│ └── avatarFetcher.js # アバター動的取得
|
|
||||||
├── dist/ # ビルド成果物
|
|
||||||
├── build-minimal.js # 最小化ビルドスクリプト
|
|
||||||
└── PROGRESS.md # 本ファイル
|
|
||||||
```
|
|
||||||
|
|
||||||
## 残存課題・継続開発項目
|
|
||||||
|
|
||||||
### 🔄 現在進行中
|
|
||||||
- 特になし(基本機能完成)
|
|
||||||
|
|
||||||
### 📋 今後の拡張予定
|
|
||||||
1. **AI連携強化**
|
|
||||||
- ai.gptとの統合
|
|
||||||
- AIコメント自動生成
|
|
||||||
- 心理分析機能統合
|
|
||||||
|
|
||||||
2. **パフォーマンス最適化**
|
|
||||||
- バンドルサイズ削減(現在1.2MB)
|
|
||||||
- 動的インポート実装
|
|
||||||
- キャッシュ戦略改善
|
|
||||||
|
|
||||||
3. **機能拡張**
|
|
||||||
- リアルタイム更新
|
|
||||||
- 通知システム
|
|
||||||
- モデレーション機能
|
|
||||||
- 多言語対応
|
|
||||||
|
|
||||||
4. **ai.log統合**
|
|
||||||
- 静的ブログジェネレーター連携
|
|
||||||
- 記事別コメント管理
|
|
||||||
- SEO最適化
|
|
||||||
|
|
||||||
### 🎯 次回セッション予定
|
|
||||||
1. ai.gpt連携の詳細設計
|
|
||||||
2. パフォーマンス最適化
|
|
||||||
3. ai.log本体との統合テスト
|
|
||||||
|
|
||||||
## 技術メモ
|
|
||||||
|
|
||||||
### 重要な解決方法
|
|
||||||
- **CSS競合**: `oauth-`プレフィックスで名前空間分離
|
|
||||||
- **アバター問題**: 3段階フォールバック(record → fresh fetch → fallback)
|
|
||||||
- **認証フロー**: session管理とDID-based認証
|
|
||||||
- **レスポンシブ**: 統一パディングでシンプル化
|
|
||||||
|
|
||||||
### 設定ファイル連携
|
|
||||||
- `./my-blog/config.toml`: ブログ設定
|
|
||||||
- `./oauth/.env.production`: OAuth設定
|
|
||||||
- `~/.config/syui/ai/log/config.json`: システム設定
|
|
||||||
|
|
||||||
## 成果物
|
|
||||||
|
|
||||||
✅ **完全に動作するOAuthコメントシステム**
|
|
||||||
- ATProto認証
|
|
||||||
- コメント投稿・表示・削除
|
|
||||||
- アバター表示
|
|
||||||
- JSON詳細表示
|
|
||||||
- テスト機能
|
|
||||||
- レスポンシブデザイン
|
|
||||||
- ailogサイトとの統合準備完了
|
|
222
oauth/README.md
222
oauth/README.md
@ -1,222 +0,0 @@
|
|||||||
# ATProto OAuth Comment System
|
|
||||||
|
|
||||||
ATProtocol(Bluesky)のOAuth認証を使用したコメントシステムです。
|
|
||||||
|
|
||||||
## プロジェクト概要
|
|
||||||
|
|
||||||
このプロジェクトは、ATProtocolネットワーク上のコメントとlangレコードを表示するWebアプリケーションです。
|
|
||||||
- 標準的なOAuth認証画面を使用
|
|
||||||
- タブ切り替えでレコード表示
|
|
||||||
- ページコンテキストに応じたフィルタリング
|
|
||||||
|
|
||||||
## ファイル構成
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── config/
|
|
||||||
│ └── env.js # 環境変数の一元管理
|
|
||||||
├── utils/
|
|
||||||
│ └── pds.js # PDS判定・API設定ユーティリティ
|
|
||||||
├── api/
|
|
||||||
│ └── atproto.js # ATProto API クライアント
|
|
||||||
├── hooks/
|
|
||||||
│ ├── useAuth.js # OAuth認証フック
|
|
||||||
│ ├── useAdminData.js # 管理者データ取得フック
|
|
||||||
│ └── usePageContext.js # ページ判定フック
|
|
||||||
├── services/
|
|
||||||
│ └── oauth.js # OAuth認証サービス
|
|
||||||
├── components/
|
|
||||||
│ ├── AuthButton.jsx # ログイン/ログアウトボタン
|
|
||||||
│ ├── RecordTabs.jsx # Lang/Commentタブ切り替え
|
|
||||||
│ ├── RecordList.jsx # レコード表示リスト
|
|
||||||
│ ├── UserLookup.jsx # ユーザー検索(未使用)
|
|
||||||
│ └── OAuthCallback.jsx # OAuth コールバック処理
|
|
||||||
└── App.jsx # メインアプリケーション
|
|
||||||
```
|
|
||||||
|
|
||||||
## 環境設定
|
|
||||||
|
|
||||||
### .env ファイル
|
|
||||||
|
|
||||||
```bash
|
|
||||||
VITE_ADMIN=ai.syui.ai # 管理者ハンドル
|
|
||||||
VITE_PDS=syu.is # デフォルトPDS
|
|
||||||
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"] # syu.is系ハンドルリスト
|
|
||||||
VITE_COLLECTION=ai.syui.log # ベースコレクション
|
|
||||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json # OAuth クライアントID
|
|
||||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback # OAuth リダイレクトURI
|
|
||||||
```
|
|
||||||
|
|
||||||
### 必要な依存関係
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"@atproto/api": "^0.15.12",
|
|
||||||
"@atproto/oauth-client-browser": "^0.3.19",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "^18.2.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 主要機能
|
|
||||||
|
|
||||||
### 1. OAuth認証システム
|
|
||||||
|
|
||||||
**実装場所**: `src/services/oauth.js`
|
|
||||||
|
|
||||||
- `@atproto/oauth-client-browser`を使用した標準OAuth実装
|
|
||||||
- bsky.social と syu.is 両方のPDSに対応
|
|
||||||
- セッション自動復元機能
|
|
||||||
|
|
||||||
**重要**: ATProtoのセッション管理は複雑なため、公式ライブラリの使用が必須です。
|
|
||||||
|
|
||||||
### 2. PDS判定システム
|
|
||||||
|
|
||||||
**実装場所**: `src/utils/pds.js`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// ハンドル判定ロジック
|
|
||||||
isSyuIsHandle(handle) → boolean
|
|
||||||
// PDS設定取得
|
|
||||||
getApiConfig(pds) → { pds, bsky, plc, web }
|
|
||||||
```
|
|
||||||
|
|
||||||
環境変数`VITE_HANDLE_LIST`と`VITE_PDS`を基に自動判定します。
|
|
||||||
|
|
||||||
### 3. コレクション取得システム
|
|
||||||
|
|
||||||
**実装場所**: `src/api/atproto.js`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 基本コレクション
|
|
||||||
collections.getBase(pds, repo, collection)
|
|
||||||
// lang コレクション(翻訳系)
|
|
||||||
collections.getLang(pds, repo, collection) // → {collection}.chat.lang
|
|
||||||
// comment コレクション(コメント系)
|
|
||||||
collections.getComment(pds, repo, collection) // → {collection}.chat.comment
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. ページコンテキスト判定
|
|
||||||
|
|
||||||
**実装場所**: `src/hooks/usePageContext.js`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// URL解析結果
|
|
||||||
{
|
|
||||||
isTopPage: boolean, // トップページかどうか
|
|
||||||
rkey: string | null, // 個別ページのrkey(/posts/xxx → xxx)
|
|
||||||
url: string // 現在のURL
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 表示ロジック
|
|
||||||
|
|
||||||
### フィルタリング
|
|
||||||
|
|
||||||
1. **トップページ**: 最新3件を表示
|
|
||||||
2. **個別ページ**: `record.value.post.url`の rkey が現在ページと一致するもののみ表示
|
|
||||||
|
|
||||||
### タブ切り替え
|
|
||||||
|
|
||||||
- Lang Records: `{collection}.chat.lang`
|
|
||||||
- Comment Records: `{collection}.chat.comment`
|
|
||||||
|
|
||||||
## 開発・デバッグ
|
|
||||||
|
|
||||||
### 起動コマンド
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run dev # 開発サーバー
|
|
||||||
npm run build # プロダクションビルド
|
|
||||||
```
|
|
||||||
|
|
||||||
### OAuth デバッグ
|
|
||||||
|
|
||||||
1. **ローカル開発**: 自動的にloopback clientが使用される
|
|
||||||
2. **本番環境**: `client-metadata.json`が必要
|
|
||||||
|
|
||||||
```json
|
|
||||||
// public/client-metadata.json
|
|
||||||
{
|
|
||||||
"client_id": "https://syui.ai/client-metadata.json",
|
|
||||||
"client_name": "ATProto Comment System",
|
|
||||||
"redirect_uris": ["https://syui.ai/oauth/callback"],
|
|
||||||
"scope": "atproto",
|
|
||||||
"grant_types": ["authorization_code", "refresh_token"],
|
|
||||||
"response_types": ["code"],
|
|
||||||
"token_endpoint_auth_method": "none",
|
|
||||||
"application_type": "web",
|
|
||||||
"dpop_bound_access_tokens": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### よくある問題
|
|
||||||
|
|
||||||
1. **セッションが保存されない**
|
|
||||||
- `@atproto/oauth-client-browser`のバージョン確認
|
|
||||||
- IndexedDBの確認(ブラウザの開発者ツール)
|
|
||||||
|
|
||||||
2. **PDS判定が正しく動作しない**
|
|
||||||
- `VITE_HANDLE_LIST`の JSON 形式を確認
|
|
||||||
- 環境変数の読み込み確認
|
|
||||||
|
|
||||||
3. **レコードが表示されない**
|
|
||||||
- 管理者アカウントの DID 解決確認
|
|
||||||
- コレクション名の確認(`{base}.chat.lang`, `{base}.chat.comment`)
|
|
||||||
|
|
||||||
## API エンドポイント
|
|
||||||
|
|
||||||
### 使用しているATProto API
|
|
||||||
|
|
||||||
1. **com.atproto.repo.describeRepo**
|
|
||||||
- ハンドル → DID, PDS解決
|
|
||||||
|
|
||||||
2. **app.bsky.actor.getProfile**
|
|
||||||
- プロフィール情報取得
|
|
||||||
|
|
||||||
3. **com.atproto.repo.listRecords**
|
|
||||||
- コレクションレコード取得
|
|
||||||
|
|
||||||
## セキュリティ
|
|
||||||
|
|
||||||
- OAuth 2.1 + PKCE による認証
|
|
||||||
- DPoP (Demonstration of Proof of Possession) 対応
|
|
||||||
- セッション情報はブラウザのIndexedDBに暗号化保存
|
|
||||||
|
|
||||||
## 今後の拡張可能性
|
|
||||||
|
|
||||||
1. **コメント投稿機能**
|
|
||||||
- 認証済みユーザーによるコメント作成
|
|
||||||
- `com.atproto.repo.putRecord` API使用
|
|
||||||
|
|
||||||
2. **リアルタイム更新**
|
|
||||||
- Jetstream WebSocket 接続
|
|
||||||
- 新しいレコードの自動表示
|
|
||||||
|
|
||||||
3. **マルチPDS対応**
|
|
||||||
- より多くのPDSへの対応
|
|
||||||
- 動的PDS判定の改善
|
|
||||||
|
|
||||||
## トラブルシューティング
|
|
||||||
|
|
||||||
### ログ確認
|
|
||||||
ブラウザの開発者ツールでコンソールログを確認してください。主要なエラーは以下の通りです:
|
|
||||||
|
|
||||||
- `OAuth initialization failed`: OAuth設定の問題
|
|
||||||
- `Failed to load admin data`: API アクセスエラー
|
|
||||||
- `Auth check failed`: セッション復元エラー
|
|
||||||
|
|
||||||
### 環境変数確認
|
|
||||||
```javascript
|
|
||||||
// 開発者ツールのコンソールで確認
|
|
||||||
console.log(import.meta.env)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 参考資料
|
|
||||||
|
|
||||||
- [ATProto OAuth Guide](https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md)
|
|
||||||
- [BrowserOAuthClient Documentation](https://github.com/bluesky-social/atproto/tree/main/packages/oauth-client-browser)
|
|
||||||
- [ATProto API Reference](https://docs.bsky.app/docs/advanced-guides/atproto-api)
|
|
@ -1,41 +0,0 @@
|
|||||||
name: Cleanup Old Deployments
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["Deploy to Cloudflare Pages"]
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
KEEP_DEPLOYMENTS: 5 # 保持するデプロイメント数
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cleanup:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Cleanup old deployments
|
|
||||||
run: |
|
|
||||||
# Get all deployments
|
|
||||||
DEPLOYMENTS=$(curl -s -X GET \
|
|
||||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json")
|
|
||||||
|
|
||||||
# Extract deployment IDs (skip the latest N deployments)
|
|
||||||
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id")
|
|
||||||
|
|
||||||
# Delete old deployments
|
|
||||||
for ID in $DEPLOYMENT_IDS; do
|
|
||||||
echo "Deleting deployment: $ID"
|
|
||||||
curl -s -X DELETE \
|
|
||||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
|
|
||||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json"
|
|
||||||
echo "Deleted deployment: $ID"
|
|
||||||
sleep 1 # Rate limiting
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Cleanup completed!"
|
|
Reference in New Issue
Block a user