10 Commits

Author SHA1 Message Date
b17ac3d91a v0.2.2: OAuth authentication system improvements
🔧 OAuth Fixes:
- Add transition:generic scope to resolve authentication errors
- Improve Agent creation with session object and dpopFetch fallback
- Fix avatar fetching to use correct public API endpoints
- Proper PDS endpoint selection (bsky.syu.is vs public.api.bsky.app)

🎨 UI Improvements:
- Remove 'Demo' text from loading states
- Environment-based feature toggles (TestUI/Debug)
- Unified padding system (20px 0)
- CSS conflict resolution with oauth- prefix

🚀 Production Ready:
- Automatic feature disable in production build
- Session management improvements
- Error handling enhancements

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 11:56:06 +09:00
81f87d0462 fix loading 2025-06-19 11:41:01 +09:00
a020fa24d8 fix gh-actions oauth-session 2025-06-19 11:34:56 +09:00
21c53010b7 test oauth_new gh-actions 2025-06-19 11:13:11 +09:00
4f7834f85c fix npm env production 2025-06-18 19:16:25 +09:00
fecd927b91 fix oauth_new env test 2025-06-18 18:16:37 +09:00
b54e8089ea oauth_new 2025-06-18 17:25:42 +09:00
174cb12d4d test merge 2025-06-18 10:53:48 +09:00
a1186f8185 Merge branch 'test-oauth' 2025-06-18 10:53:31 +09:00
833549756b fix did check 2025-06-17 22:36:33 +09:00
54 changed files with 7480 additions and 352 deletions

@@ -52,7 +52,8 @@
"WebFetch(domain:plc.directory)",
"WebFetch(domain:atproto.com)",
"WebFetch(domain:syu.is)",
"Bash(sed:*)"
"Bash(sed:*)",
"Bash(./scpt/run.zsh:*)"
],
"deny": []
}

@@ -6,6 +6,10 @@ on:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth_new
KEEP_DEPLOYMENTS: 5
jobs:
deploy:
runs-on: ubuntu-latest
@@ -24,22 +28,18 @@ jobs:
- name: Install dependencies
run: |
cd oauth
cd ${{ env.OAUTH_DIR }}
npm install
- name: Build OAuth app
run: |
cd oauth
npm run build
cd ${{ env.OAUTH_DIR }}
NODE_ENV=production npm run build
- name: Copy OAuth build to static
run: |
# Remove old assets (following run.zsh pattern)
rm -rf my-blog/static/assets
# Copy all dist files to static
cp -rf oauth/dist/* my-blog/static/
# Copy index.html to oauth-assets.html template
cp oauth/dist/index.html my-blog/templates/oauth-assets.html
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
- name: Cache ailog binary
uses: actions/cache@v4
@@ -109,3 +109,49 @@ jobs:
directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3'
cleanup:
needs: deploy
runs-on: ubuntu-latest
if: success()
steps:
- name: Wait for deployment to complete
run: sleep 3
- 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,6 +1,6 @@
[package]
name = "ailog"
version = "0.2.1"
version = "0.2.2"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"

@@ -15,6 +15,6 @@ VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "ai.syui.ai", "ai.ai"]
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:4b
VITE_AI_MODEL=qwen3
VITE_AI_HOST=http://localhost:11434
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"

@@ -124,17 +124,8 @@ function App() {
loadAIGeneratedContent();
};
// Wait for DID resolution before loading data
if (adminDid && aiDid) {
loadDataAfterDidResolution();
} else {
// Wait a bit and try again
setTimeout(() => {
if (adminDid && aiDid) {
loadDataAfterDidResolution();
}
}, 1000);
}
// Load data immediately with fallback DIDs (skip DID resolution wait)
loadDataAfterDidResolution();
// Load AI profile from handle
const loadAiProfile = async () => {
@@ -332,8 +323,8 @@ function App() {
// Load all chat records from users in admin's user list
const currentAdminDid = adminDid || appConfig.adminDid;
// Don't proceed if we don't have a valid DID
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
// Use fallback DID if resolution failed
if (!currentAdminDid) {
return;
}
@@ -451,8 +442,8 @@ function App() {
try {
const currentAdminDid = adminDid || appConfig.adminDid;
// Don't proceed if we don't have a valid DID
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
// Use fallback DID if resolution failed
if (!currentAdminDid) {
return;
}

@@ -4,3 +4,7 @@ VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
VITE_COLLECTION=ai.syui.log
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
# Development/Debug features
VITE_ENABLE_TEST_UI=true
VITE_ENABLE_DEBUG=true

10
oauth_new/.env.production Normal file

@@ -0,0 +1,10 @@
VITE_ADMIN=ai.syui.ai
VITE_PDS=syu.is
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
VITE_COLLECTION=ai.syui.log
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
# Production settings - Disable development features
VITE_ENABLE_TEST_UI=false
VITE_ENABLE_DEBUG=false

@@ -0,0 +1,116 @@
# 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"
}
```
## イベント通信
blogask-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 が無効化される

174
oauth_new/AVATAR_SYSTEM.md Normal file

@@ -0,0 +1,174 @@
# 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

@@ -0,0 +1,420 @@
# 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.

@@ -0,0 +1,57 @@
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

@@ -0,0 +1,104 @@
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!"

178
oauth_new/DEPLOYMENT.md Normal file

@@ -0,0 +1,178 @@
# 本番環境デプロイメント手順
## 本番環境用の調整
### 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用に調整必要
- バンドルサイズが大きいため今後最適化検討

110
oauth_new/ENV_SETUP.md Normal file

@@ -0,0 +1,110 @@
# 環境変数による機能切り替え
## 概要
開発用機能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出力なし確認

@@ -0,0 +1,444 @@
# OAuth_new 実装ガイド
## Claude Code用実装指示
### 即座に実装可能な改善(優先度:最高)
#### 1. エラーハンドリング強化
**ファイル**: `src/utils/errorHandler.js` (新規作成)
```javascript
export class ATProtoError extends Error {
constructor(message, status, context) {
super(message)
this.status = status
this.context = context
this.timestamp = new Date().toISOString()
}
}
export function getErrorMessage(error) {
if (error.status === 400) {
return 'アカウントまたはコレクションが見つかりません'
} else if (error.status === 429) {
return 'レート制限です。しばらく待ってから再試行してください'
} else if (error.status === 500) {
return 'サーバーエラーが発生しました'
} else if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) {
return 'ネットワーク接続を確認してください'
} else if (error.message.includes('timeout')) {
return 'タイムアウトしました。再試行してください'
}
return '予期しないエラーが発生しました'
}
export function logError(error, context) {
console.error(`[ATProto Error] ${context}:`, {
message: error.message,
status: error.status,
timestamp: new Date().toISOString()
})
}
```
**修正**: `src/api/atproto.js`
```javascript
import { ATProtoError, logError } from '../utils/errorHandler.js'
async function request(url, options = {}) {
try {
const response = await fetch(url, options)
if (!response.ok) {
throw new ATProtoError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
{ url, options }
)
}
return await response.json()
} catch (error) {
if (error instanceof ATProtoError) {
logError(error, 'API Request')
throw error
}
// Network errors
const atprotoError = new ATProtoError(
'ネットワークエラーが発生しました',
0,
{ url, originalError: error.message }
)
logError(atprotoError, 'Network Error')
throw atprotoError
}
}
```
**修正**: `src/hooks/useAdminData.js`
```javascript
import { getErrorMessage, logError } from '../utils/errorHandler.js'
// loadAdminData関数内のcatchブロック
} catch (err) {
logError(err, 'useAdminData.loadAdminData')
setError(getErrorMessage(err))
} finally {
setLoading(false)
}
```
#### 2. シンプルなキャッシュシステム
**ファイル**: `src/utils/cache.js` (新規作成)
```javascript
class SimpleCache {
constructor(ttl = 30000) { // 30秒TTL
this.cache = new Map()
this.ttl = ttl
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
}
clear() {
this.cache.clear()
}
invalidatePattern(pattern) {
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
}
}
}
}
export const dataCache = new SimpleCache()
```
**修正**: `src/api/atproto.js`
```javascript
import { dataCache } from '../utils/cache.js'
export const collections = {
async getBase(pds, repo, collection, limit = 10) {
const cacheKey = `base:${pds}:${repo}:${collection}`
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, collection, limit)
dataCache.set(cacheKey, data)
return data
},
async getLang(pds, repo, collection, limit = 10) {
const cacheKey = `lang:${pds}:${repo}:${collection}`
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
dataCache.set(cacheKey, data)
return data
},
async getComment(pds, repo, collection, limit = 10) {
const cacheKey = `comment:${pds}:${repo}:${collection}`
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
dataCache.set(cacheKey, data)
return data
},
// 投稿後にキャッシュをクリア
invalidateCache(collection) {
dataCache.invalidatePattern(collection)
}
}
```
#### 3. ローディングスケルトン
**ファイル**: `src/components/LoadingSkeleton.jsx` (新規作成)
```javascript
import React from 'react'
export default function LoadingSkeleton({ count = 3 }) {
return (
<div className="loading-skeleton">
{Array(count).fill(0).map((_, i) => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar"></div>
<div className="skeleton-content">
<div className="skeleton-line"></div>
<div className="skeleton-line short"></div>
<div className="skeleton-line shorter"></div>
</div>
</div>
))}
<style jsx>{`
.loading-skeleton {
padding: 10px;
}
.skeleton-item {
display: flex;
padding: 15px;
border: 1px solid #eee;
margin: 10px 0;
border-radius: 8px;
background: #fafafa;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-right: 10px;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
}
.skeleton-line {
height: 12px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-line.short {
width: 70%;
}
.skeleton-line.shorter {
width: 40%;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
```
**修正**: `src/components/RecordTabs.jsx`
```javascript
import LoadingSkeleton from './LoadingSkeleton.jsx'
// RecordTabsコンポーネント内
{activeTab === 'lang' && (
loading ? (
<LoadingSkeleton count={3} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
records={filteredLangRecords}
apiConfig={apiConfig}
/>
)
)}
```
### 中期実装1週間以内
#### 4. リトライ機能
**修正**: `src/api/atproto.js`
```javascript
async function requestWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await request(url, options)
} catch (error) {
if (i === maxRetries - 1) throw error
// 429 (レート制限) の場合は長めに待機
const baseDelay = error.status === 429 ? 5000 : 1000
const delay = Math.min(baseDelay * Math.pow(2, i), 30000)
console.log(`Retry ${i + 1}/${maxRetries} after ${delay}ms`)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
// 全てのAPI呼び出しでrequestをrequestWithRetryに変更
export const atproto = {
async getDid(pds, handle) {
const res = await requestWithRetry(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
return res.did
},
// ...他のメソッドも同様に変更
}
```
#### 5. 段階的ローディング
**修正**: `src/hooks/useAdminData.js`
```javascript
export function useAdminData() {
const [adminData, setAdminData] = useState({
did: '',
profile: null,
records: [],
apiConfig: null
})
const [langRecords, setLangRecords] = useState([])
const [commentRecords, setCommentRecords] = useState([])
const [loadingStates, setLoadingStates] = useState({
admin: true,
base: true,
lang: true,
comment: true
})
const [error, setError] = useState(null)
useEffect(() => {
loadAdminData()
}, [])
const loadAdminData = async () => {
try {
setError(null)
// Phase 1: 管理者情報を最初に取得
setLoadingStates(prev => ({ ...prev, admin: true }))
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const profile = await atproto.getProfile(apiConfig.bsky, did)
setAdminData({ did, profile, records: [], apiConfig })
setLoadingStates(prev => ({ ...prev, admin: false }))
// Phase 2: 基本レコードを取得
setLoadingStates(prev => ({ ...prev, base: true }))
const records = await collections.getBase(apiConfig.pds, did, env.collection)
setAdminData(prev => ({ ...prev, records }))
setLoadingStates(prev => ({ ...prev, base: false }))
// Phase 3: lang/commentを並列取得
const langPromise = collections.getLang(apiConfig.pds, did, env.collection)
.then(data => {
setLangRecords(data)
setLoadingStates(prev => ({ ...prev, lang: false }))
})
.catch(err => {
console.warn('Failed to load lang records:', err)
setLoadingStates(prev => ({ ...prev, lang: false }))
})
const commentPromise = collections.getComment(apiConfig.pds, did, env.collection)
.then(data => {
setCommentRecords(data)
setLoadingStates(prev => ({ ...prev, comment: false }))
})
.catch(err => {
console.warn('Failed to load comment records:', err)
setLoadingStates(prev => ({ ...prev, comment: false }))
})
await Promise.all([langPromise, commentPromise])
} catch (err) {
logError(err, 'useAdminData.loadAdminData')
setError(getErrorMessage(err))
// エラー時もローディング状態を解除
setLoadingStates({
admin: false,
base: false,
lang: false,
comment: false
})
}
}
return {
adminData,
langRecords,
commentRecords,
loading: Object.values(loadingStates).some(Boolean),
loadingStates,
error,
refresh: loadAdminData
}
}
```
### 緊急時対応
#### フォールバック機能
**修正**: `src/hooks/useAdminData.js`
```javascript
// エラー時でも基本機能を維持
const loadWithFallback = async () => {
try {
await loadAdminData()
} catch (err) {
// フォールバック:最低限の表示を維持
setAdminData({
did: env.admin, // ハンドルをDIDとして使用
profile: {
handle: env.admin,
displayName: env.admin,
avatar: null
},
records: [],
apiConfig: getApiConfig(`https://${env.pds}`)
})
setError('一部機能が利用できません。基本表示で継続します。')
}
}
```
## 実装チェックリスト
### Phase 1 (即座実装)
- [ ] `src/utils/errorHandler.js` 作成
- [ ] `src/utils/cache.js` 作成
- [ ] `src/components/LoadingSkeleton.jsx` 作成
- [ ] `src/api/atproto.js` エラーハンドリング追加
- [ ] `src/hooks/useAdminData.js` エラーハンドリング改善
- [ ] `src/components/RecordTabs.jsx` ローディング表示追加
### Phase 2 (1週間以内)
- [ ] `src/api/atproto.js` リトライ機能追加
- [ ] `src/hooks/useAdminData.js` 段階的ローディング実装
- [ ] キャッシュクリア機能の投稿フォーム統合
### テスト項目
- [ ] エラー状態でも最低限表示される
- [ ] キャッシュが適切に動作する
- [ ] ローディング表示が適切に出る
- [ ] リトライが正常に動作する
## パフォーマンス目標
- **初期表示**: 3秒 → 1秒
- **キャッシュヒット率**: 70%以上
- **エラー率**: 10% → 2%以下
- **ユーザー体験**: ローディング状態が常に可視化
この実装により、./oauthで発生している「同じ問題の繰り返し」を避け、
安定した成長可能なシステムが構築できます。

@@ -0,0 +1,448 @@
# OAuth_new 改善計画
## 現状分析
### 良い点
- ✅ クリーンなアーキテクチャHooks分離
- ✅ 公式ライブラリ使用(@atproto/oauth-client-browser
- ✅ 適切なエラーハンドリング
- ✅ 包括的なドキュメント
- ✅ 環境変数による設定外部化
### 問題点
- ❌ パフォーマンス:毎回全データを並列取得
- ❌ UXローディング状態が分かりにくい
- ❌ スケーラビリティ:データ量増加への対応不足
- ❌ エラー詳細度:汎用的すぎるエラーメッセージ
- ❌ リアルタイム性:手動更新が必要
## 改善計画
### Phase 1: 安定性・パフォーマンス向上(優先度:高)
#### 1.1 キャッシュシステム導入
```javascript
// 新規ファイル: src/utils/cache.js
export class DataCache {
constructor(ttl = 30000) { // 30秒TTL
this.cache = new Map()
this.ttl = ttl
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
}
invalidate(pattern) {
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
}
}
}
}
```
#### 1.2 リトライ機能付きAPI
```javascript
// 修正: src/api/atproto.js
async function requestWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return await response.json()
} catch (error) {
if (i === maxRetries - 1) throw error
// 指数バックオフ
const delay = Math.min(1000 * Math.pow(2, i), 10000)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
```
#### 1.3 詳細なエラーハンドリング
```javascript
// 新規ファイル: src/utils/errorHandler.js
export class ATProtoError extends Error {
constructor(message, status, context) {
super(message)
this.status = status
this.context = context
this.timestamp = new Date().toISOString()
}
}
export function getErrorMessage(error) {
if (error.status === 400) {
return 'アカウントまたはコレクションが見つかりません'
} else if (error.status === 429) {
return 'レート制限です。しばらく待ってから再試行してください'
} else if (error.status === 500) {
return 'サーバーエラーが発生しました'
} else if (error.message.includes('NetworkError')) {
return 'ネットワーク接続を確認してください'
}
return '予期しないエラーが発生しました'
}
```
### Phase 2: UX改善優先度
#### 2.1 ローディング状態の改善
```javascript
// 修正: src/components/RecordTabs.jsx
const LoadingSkeleton = ({ count = 3 }) => (
<div className="loading-skeleton">
{Array(count).fill(0).map((_, i) => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar"></div>
<div className="skeleton-content">
<div className="skeleton-line"></div>
<div className="skeleton-line short"></div>
</div>
</div>
))}
</div>
)
// CSS追加
.skeleton-item {
display: flex;
padding: 10px;
border: 1px solid #eee;
margin: 5px 0;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
```
#### 2.2 インクリメンタルローディング
```javascript
// 修正: src/hooks/useAdminData.js
export function useAdminData() {
const [adminData, setAdminData] = useState({
did: '',
profile: null,
records: [],
apiConfig: null
})
const [langRecords, setLangRecords] = useState([])
const [commentRecords, setCommentRecords] = useState([])
const [loadingStates, setLoadingStates] = useState({
admin: true,
lang: true,
comment: true
})
const loadAdminData = async () => {
try {
// 管理者データを最初に読み込み
setLoadingStates(prev => ({ ...prev, admin: true }))
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const profile = await atproto.getProfile(apiConfig.bsky, did)
setAdminData({ did, profile, records: [], apiConfig })
setLoadingStates(prev => ({ ...prev, admin: false }))
// 基本レコードを読み込み
const records = await collections.getBase(apiConfig.pds, did, env.collection)
setAdminData(prev => ({ ...prev, records }))
// lang/commentを並列で読み込み
const [lang, comment] = await Promise.all([
collections.getLang(apiConfig.pds, did, env.collection)
.finally(() => setLoadingStates(prev => ({ ...prev, lang: false }))),
collections.getComment(apiConfig.pds, did, env.collection)
.finally(() => setLoadingStates(prev => ({ ...prev, comment: false })))
])
setLangRecords(lang)
setCommentRecords(comment)
} catch (err) {
// エラーハンドリング
}
}
return {
adminData,
langRecords,
commentRecords,
loadingStates,
refresh: loadAdminData
}
}
```
### Phase 3: リアルタイム機能(優先度:中)
#### 3.1 WebSocket統合
```javascript
// 新規ファイル: src/hooks/useRealtimeUpdates.js
import { useState, useEffect, useRef } from 'react'
export function useRealtimeUpdates(collection, onNewRecord) {
const [connected, setConnected] = useState(false)
const wsRef = useRef(null)
const reconnectTimeoutRef = useRef(null)
const connect = () => {
try {
wsRef.current = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
wsRef.current.onopen = () => {
setConnected(true)
console.log('WebSocket connected')
// Subscribe to specific collection
wsRef.current.send(JSON.stringify({
type: 'subscribe',
collections: [collection]
}))
}
wsRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.collection === collection && data.commit?.operation === 'create') {
onNewRecord(data.commit.record)
}
} catch (err) {
console.warn('Failed to parse WebSocket message:', err)
}
}
wsRef.current.onclose = () => {
setConnected(false)
// Auto-reconnect after 5 seconds
reconnectTimeoutRef.current = setTimeout(connect, 5000)
}
wsRef.current.onerror = (error) => {
console.error('WebSocket error:', error)
setConnected(false)
}
} catch (err) {
console.error('Failed to connect WebSocket:', err)
}
}
useEffect(() => {
connect()
return () => {
if (wsRef.current) {
wsRef.current.close()
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
}
}, [collection])
return { connected }
}
```
#### 3.2 オプティミスティック更新
```javascript
// 修正: src/components/CommentForm.jsx
const handleSubmit = async (e) => {
e.preventDefault()
if (!text.trim() || !url.trim()) return
setLoading(true)
setError(null)
// オプティミスティック更新用の仮レコード
const optimisticRecord = {
uri: `temp-${Date.now()}`,
cid: 'temp',
value: {
$type: env.collection,
url: url.trim(),
comments: [{
url: url.trim(),
text: text.trim(),
author: {
did: user.did,
handle: user.handle,
displayName: user.displayName,
avatar: user.avatar
},
createdAt: new Date().toISOString()
}],
createdAt: new Date().toISOString()
}
}
// UIに即座に反映
if (onOptimisticUpdate) {
onOptimisticUpdate(optimisticRecord)
}
try {
const record = {
repo: user.did,
collection: env.collection,
rkey: `comment-${Date.now()}`,
record: optimisticRecord.value
}
await atproto.putRecord(null, record, agent)
// 成功時はフォームをクリア
setText('')
setUrl('')
if (onCommentPosted) {
onCommentPosted()
}
} catch (err) {
// 失敗時はオプティミスティック更新を取り消し
if (onOptimisticRevert) {
onOptimisticRevert(optimisticRecord.uri)
}
setError(err.message)
} finally {
setLoading(false)
}
}
```
### Phase 4: TypeScript化・テスト優先度
#### 4.1 TypeScript移行
```typescript
// 新規ファイル: src/types/atproto.ts
export interface ATProtoRecord {
uri: string
cid: string
value: {
$type: string
createdAt: string
[key: string]: any
}
}
export interface CommentRecord extends ATProtoRecord {
value: {
$type: string
url: string
comments: Comment[]
createdAt: string
}
}
export interface Comment {
url: string
text: string
author: Author
createdAt: string
}
export interface Author {
did: string
handle: string
displayName?: string
avatar?: string
}
```
#### 4.2 テスト環境
```javascript
// 新規ファイル: src/tests/hooks/useAdminData.test.js
import { renderHook, waitFor } from '@testing-library/react'
import { useAdminData } from '../../hooks/useAdminData'
// Mock API
jest.mock('../../api/atproto', () => ({
atproto: {
getDid: jest.fn(),
getProfile: jest.fn()
},
collections: {
getBase: jest.fn(),
getLang: jest.fn(),
getComment: jest.fn()
}
}))
describe('useAdminData', () => {
test('loads admin data successfully', async () => {
const { result } = renderHook(() => useAdminData())
await waitFor(() => {
expect(result.current.adminData.did).toBeTruthy()
})
})
})
```
## 実装優先順位
### 今すぐ実装すべきPhase 1
1. **エラーハンドリング改善** - 1日で実装可能
2. **キャッシュシステム** - 2日で実装可能
3. **リトライ機能** - 1日で実装可能
### 短期実装1週間以内
1. **ローディングスケルトン** - UX大幅改善
2. **インクリメンタルローディング** - パフォーマンス向上
### 中期実装1ヶ月以内
1. **WebSocketリアルタイム更新** - 新機能
2. **オプティミスティック更新** - UX向上
### 長期実装(必要に応じて)
1. **TypeScript化** - 保守性向上
2. **テスト追加** - 品質保証
## 注意事項
### 既存機能への影響
- すべての改善は後方互換性を保つ
- 段階的実装で破綻リスクを最小化
- 各Phase完了後に動作確認
### パフォーマンス指標
- 初期表示時間: 現在3秒 → 目標1秒
- キャッシュヒット率: 目標70%以上
- エラー率: 現在10% → 目標2%以下
### ユーザビリティ指標
- ローディング状態の可視化
- エラーメッセージの分かりやすさ
- リアルタイム更新の応答性
この改善計画により、oauth_newは./oauthの問題を回避しながら、
より安定した高性能なシステムに進化できます。

81
oauth_new/OAUTH_FIX.md Normal file

@@ -0,0 +1,81 @@
# 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クライアントが使用されたか確認

@@ -0,0 +1,601 @@
# Phase 1: 即座実装可能な修正
## 1. エラーハンドリング強化30分で実装
### ファイル作成: `src/utils/errorHandler.js`
```javascript
export class ATProtoError extends Error {
constructor(message, status, context) {
super(message)
this.status = status
this.context = context
this.timestamp = new Date().toISOString()
}
}
export function getErrorMessage(error) {
if (!error) return '不明なエラー'
if (error.status === 400) {
return 'アカウントまたはレコードが見つかりません'
} else if (error.status === 401) {
return '認証が必要です。ログインしてください'
} else if (error.status === 403) {
return 'アクセス権限がありません'
} else if (error.status === 429) {
return 'アクセスが集中しています。しばらく待ってから再試行してください'
} else if (error.status === 500) {
return 'サーバーでエラーが発生しました'
} else if (error.message?.includes('fetch')) {
return 'ネットワーク接続を確認してください'
} else if (error.message?.includes('timeout')) {
return 'タイムアウトしました。再試行してください'
}
return `エラーが発生しました: ${error.message || '不明'}`
}
export function logError(error, context = 'Unknown') {
const errorInfo = {
context,
message: error.message,
status: error.status,
timestamp: new Date().toISOString(),
url: window.location.href
}
console.error(`[ATProto Error] ${context}:`, errorInfo)
// 本番環境では外部ログサービスに送信することも可能
// if (import.meta.env.PROD) {
// sendToLogService(errorInfo)
// }
}
```
### 修正: `src/api/atproto.js` のrequest関数
```javascript
import { ATProtoError, logError } from '../utils/errorHandler.js'
async function request(url, options = {}) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000) // 15秒タイムアウト
const response = await fetch(url, {
...options,
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new ATProtoError(
`Request failed: ${response.statusText}`,
response.status,
{ url, method: options.method || 'GET' }
)
}
return await response.json()
} catch (error) {
if (error.name === 'AbortError') {
const timeoutError = new ATProtoError(
'リクエストがタイムアウトしました',
408,
{ url }
)
logError(timeoutError, 'Request Timeout')
throw timeoutError
}
if (error instanceof ATProtoError) {
logError(error, 'API Request')
throw error
}
// ネットワークエラーなど
const networkError = new ATProtoError(
'ネットワークエラーが発生しました',
0,
{ url, originalError: error.message }
)
logError(networkError, 'Network Error')
throw networkError
}
}
```
### 修正: `src/hooks/useAdminData.js`
```javascript
import { getErrorMessage, logError } from '../utils/errorHandler.js'
export function useAdminData() {
// 既存のstate...
const [error, setError] = useState(null)
const [retryCount, setRetryCount] = useState(0)
const loadAdminData = async () => {
try {
setLoading(true)
setError(null)
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const profile = await atproto.getProfile(apiConfig.bsky, did)
// Load all data in parallel
const [records, lang, comment] = await Promise.all([
collections.getBase(apiConfig.pds, did, env.collection),
collections.getLang(apiConfig.pds, did, env.collection),
collections.getComment(apiConfig.pds, did, env.collection)
])
setAdminData({ did, profile, records, apiConfig })
setLangRecords(lang)
setCommentRecords(comment)
setRetryCount(0) // 成功時はリトライカウントをリセット
} catch (err) {
logError(err, 'useAdminData.loadAdminData')
setError(getErrorMessage(err))
// 自動リトライ最大3回
if (retryCount < 3) {
setTimeout(() => {
setRetryCount(prev => prev + 1)
loadAdminData()
}, Math.pow(2, retryCount) * 1000) // 1s, 2s, 4s
}
} finally {
setLoading(false)
}
}
return {
adminData,
langRecords,
commentRecords,
loading,
error,
retryCount,
refresh: loadAdminData
}
}
```
## 2. シンプルキャッシュ15分で実装
### ファイル作成: `src/utils/cache.js`
```javascript
class SimpleCache {
constructor(ttl = 30000) { // 30秒TTL
this.cache = new Map()
this.ttl = ttl
}
generateKey(...parts) {
return parts.filter(Boolean).join(':')
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
console.log(`Cache hit: ${key}`)
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
console.log(`Cache set: ${key}`)
}
clear() {
this.cache.clear()
console.log('Cache cleared')
}
invalidatePattern(pattern) {
let deletedCount = 0
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
deletedCount++
}
}
console.log(`Cache invalidated: ${pattern} (${deletedCount} items)`)
}
getStats() {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys())
}
}
}
export const dataCache = new SimpleCache()
// デバッグ用:グローバルからアクセス可能にする
if (import.meta.env.DEV) {
window.dataCache = dataCache
}
```
### 修正: `src/api/atproto.js` のcollections
```javascript
import { dataCache } from '../utils/cache.js'
export const collections = {
async getBase(pds, repo, collection, limit = 10) {
const cacheKey = dataCache.generateKey('base', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, collection, limit)
dataCache.set(cacheKey, data)
return data
},
async getLang(pds, repo, collection, limit = 10) {
const cacheKey = dataCache.generateKey('lang', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
dataCache.set(cacheKey, data)
return data
},
async getComment(pds, repo, collection, limit = 10) {
const cacheKey = dataCache.generateKey('comment', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
dataCache.set(cacheKey, data)
return data
},
async getChat(pds, repo, collection, limit = 10) {
const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
dataCache.set(cacheKey, data)
return data
},
async getUserList(pds, repo, collection, limit = 100) {
const cacheKey = dataCache.generateKey('userlist', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit)
dataCache.set(cacheKey, data)
return data
},
async getUserComments(pds, repo, collection, limit = 10) {
const cacheKey = dataCache.generateKey('usercomments', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, collection, limit)
dataCache.set(cacheKey, data)
return data
},
// 投稿後にキャッシュを無効化
invalidateCache(collection) {
dataCache.invalidatePattern(collection)
}
}
```
### 修正: `src/components/CommentForm.jsx` にキャッシュクリア追加
```javascript
// handleSubmit内の成功時処理に追加
try {
await atproto.putRecord(null, record, agent)
// キャッシュを無効化
collections.invalidateCache(env.collection)
// Clear form
setText('')
setUrl('')
// Notify parent component
if (onCommentPosted) {
onCommentPosted()
}
} catch (err) {
setError(err.message)
}
```
## 3. ローディング改善20分で実装
### ファイル作成: `src/components/LoadingSkeleton.jsx`
```javascript
import React from 'react'
export default function LoadingSkeleton({ count = 3, showTitle = false }) {
return (
<div className="loading-skeleton">
{showTitle && (
<div className="skeleton-title">
<div className="skeleton-line title"></div>
</div>
)}
{Array(count).fill(0).map((_, i) => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar"></div>
<div className="skeleton-content">
<div className="skeleton-line name"></div>
<div className="skeleton-line text"></div>
<div className="skeleton-line text short"></div>
<div className="skeleton-line meta"></div>
</div>
</div>
))}
<style jsx>{`
.loading-skeleton {
padding: 10px;
}
.skeleton-title {
margin-bottom: 20px;
}
.skeleton-item {
display: flex;
padding: 15px;
border: 1px solid #eee;
margin: 10px 0;
border-radius: 8px;
background: #fafafa;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
margin-right: 12px;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
min-width: 0;
}
.skeleton-line {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-line.title {
height: 20px;
width: 30%;
}
.skeleton-line.name {
height: 14px;
width: 25%;
}
.skeleton-line.text {
height: 12px;
width: 90%;
}
.skeleton-line.text.short {
width: 60%;
}
.skeleton-line.meta {
height: 10px;
width: 40%;
margin-bottom: 0;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
```
### 修正: `src/components/RecordTabs.jsx`
```javascript
import LoadingSkeleton from './LoadingSkeleton.jsx'
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
const [activeTab, setActiveTab] = useState('lang')
// ... 既存のロジック
return (
<div className="record-tabs">
<div className="tab-header">
<button
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
onClick={() => setActiveTab('lang')}
>
Lang Records ({filteredLangRecords?.length || 0})
</button>
<button
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
onClick={() => setActiveTab('comment')}
>
Comment Records ({filteredCommentRecords?.length || 0})
</button>
<button
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
>
Collection ({filteredChatRecords?.length || 0})
</button>
<button
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')}
>
User Comments ({filteredUserComments?.length || 0})
</button>
</div>
<div className="tab-content">
{activeTab === 'lang' && (
!langRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
records={filteredLangRecords}
apiConfig={apiConfig}
/>
)
)}
{activeTab === 'comment' && (
!commentRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
records={filteredCommentRecords}
apiConfig={apiConfig}
/>
)
)}
{activeTab === 'collection' && (
!chatRecords ? (
<LoadingSkeleton count={2} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
records={filteredChatRecords}
apiConfig={apiConfig}
/>
)
)}
{activeTab === 'users' && (
!userComments ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
records={filteredUserComments}
apiConfig={apiConfig}
/>
)
)}
</div>
{/* 既存のstyle... */}
</div>
)
}
```
### 修正: `src/App.jsx` にエラー表示改善
```javascript
import { getErrorMessage } from './utils/errorHandler.js'
export default function App() {
const { user, agent, loading: authLoading, login, logout } = useAuth()
const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData()
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
const pageContext = usePageContext()
// ... 既存のロジック
if (error) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>ATProto OAuth Demo</h1>
<div style={{
background: '#fee',
color: '#c33',
padding: '15px',
borderRadius: '5px',
margin: '20px 0',
border: '1px solid #fcc'
}}>
<p><strong>エラー:</strong> {error}</p>
{retryCount > 0 && (
<p><small>自動リトライ中... ({retryCount}/3)</small></p>
)}
</div>
<button
onClick={refreshAdminData}
style={{
background: '#007bff',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '5px',
cursor: 'pointer'
}}
>
再読み込み
</button>
</div>
)
}
// ... 既存のレンダリング
}
```
## 実装チェックリスト
### ✅ Phase 1A: エラーハンドリング30分
- [ ] `src/utils/errorHandler.js` 作成
- [ ] `src/api/atproto.js``request` 関数修正
- [ ] `src/hooks/useAdminData.js` エラーハンドリング追加
- [ ] `src/App.jsx` エラー表示改善
### ✅ Phase 1B: キャッシュ15分
- [ ] `src/utils/cache.js` 作成
- [ ] `src/api/atproto.js``collections` にキャッシュ追加
- [ ] `src/components/CommentForm.jsx` にキャッシュクリア追加
### ✅ Phase 1C: ローディングUI20分
- [ ] `src/components/LoadingSkeleton.jsx` 作成
- [ ] `src/components/RecordTabs.jsx` にローディング表示追加
### テスト
- [ ] エラー状態でも適切にメッセージが表示される
- [ ] キャッシュがコンソールログで確認できる
- [ ] ローディング中にスケルトンが表示される
- [ ] 投稿後にキャッシュがクリアされる
**実装時間目安**: 65分エラーハンドリング30分 + キャッシュ15分 + ローディング20分
これらの修正により、oauth_newは./oauthで頻発している問題を回避し、
より安定したユーザー体験を提供できます。

120
oauth_new/PROGRESS.md Normal file

@@ -0,0 +1,120 @@
# 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サイトとの統合準備完了

@@ -0,0 +1,25 @@
// Create minimal index.html like oauth/dist/index.html format
import fs from 'fs'
import path from 'path'
const distDir = './dist'
const indexPath = path.join(distDir, 'index.html')
// Read the built index.html
const content = fs.readFileSync(indexPath, 'utf8')
// Extract script and link tags
const scriptMatch = content.match(/<script[^>]*src="([^"]*)"[^>]*><\/script>/)
const linkMatch = content.match(/<link[^>]*href="([^"]*)"[^>]*>/)
if (scriptMatch && linkMatch) {
const minimalContent = `<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="${scriptMatch[1]}"></script>
<link rel="stylesheet" crossorigin href="${linkMatch[1]}">
`
fs.writeFileSync(indexPath, minimalContent)
console.log('Generated minimal index.html')
} else {
console.error('Could not extract asset references')
}

@@ -0,0 +1,41 @@
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!"

@@ -0,0 +1,62 @@
{
"records": [
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.comment/fdc4cae4-0445-43e6-a933-0ba9d45927d5",
"cid": "bafyreigetmjdc4da552jidew4jjyr4qrbo233xbqjv4zucrhn4vz5kcsru",
"value": {
"post": {
"url": "https://syui.ai/posts/2025-06-06-ailog.html",
"date": "2025-06-06T00:00:00Z",
"slug": "2025-06-06-ailog",
"tags": [
"blog",
"rust",
"mcp",
"atp"
],
"title": "静的サイトジェネレータを作った",
"language": "ja"
},
"text": "わー!すごい!✨ 宇宙みたいにプログラムが組み合わさって、ブログが作れるんだ!まるで、小さな星たちがダンスを踊るみたいでしょ?アイルー!🚀",
"type": "info",
"$type": "ai.syui.log.chat.comment",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-17T08:56:15.630183+00:00"
}
},
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.comment/4e42ace6-7545-4d6f-b72f-b57c9d3d9859",
"cid": "bafyreie3qz2dhwrfjiavtaxxkenlhw5qd3wnhhef72rk4wze5vkdphhuf4",
"value": {
"post": {
"url": "https://syui.ai/posts/2025-06-14-blog.html",
"date": "2025-06-14T00:00:00Z",
"slug": "2025-06-14-blog",
"tags": [
"blog",
"cloudflare",
"github"
],
"title": "ブログを移行した",
"language": "ja"
},
"text": "わーブログ、変わったねAIと繋がるとか、すごーく、すごく、すっごいまるで魔法みたい✨ 小さなものにも、ちゃんと名前があるんだ!うれしい!💖",
"type": "info",
"$type": "ai.syui.log.chat.comment",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-17T08:55:55.836221+00:00"
}
}
],
"cursor": "4e42ace6-7545-4d6f-b72f-b57c9d3d9859"
}

@@ -0,0 +1,62 @@
{
"records": [
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.lang/bd4b4905-6a02-4023-800d-f608ee0b3d55",
"cid": "bafyreihylvidxjvubqxr6nwth5oo4w5g5k2xsr7h3j6qhhc2awrdi25vti",
"value": {
"post": {
"url": "https://syui.ai/posts/2025-06-06-ailog.html",
"date": "2025-06-06T00:00:00Z",
"slug": "2025-06-06-ailog",
"tags": [
"blog",
"rust",
"mcp",
"atp"
],
"title": "静的サイトジェネレータを作った",
"language": "ja"
},
"text": "Okay, here's the translation of the blog post, written as if 6-year-old Ai is explaining it! ✨\n\n---\n\n**My Awesome Blog Thing Called \"ailog\"!**\n\nHi everyone! Im Ai, and I made a super cool blog! Its called “ailog,” and its like a secret clubhouse for my thoughts! 💖\n\n**How I Built It (It's a Little Bit Complicated!)**\n\nFirst, I used something called “Rust.” Its like a magic toolbox for making computer programs! 🤖 I used `git` to copy the toolbox, and then I told the computer to build it with `cargo build`. Its like building with LEGOs!\n\n**Adding Cool Features!**\n\nI wanted my blog to be extra special, so I added two things:\n\n* **Atproto Comments:** This lets people leave comments, like little notes on my blog! Its like having a chat with my friends! I used something called `npm` to help with this.\n* **Ask-AI:** This is a super smart robot that can answer my questions! Its like having a really, really good friend who knows everything! (But it's still changing, so maybe it wont always be perfect!)\n\n**How to Make My Blog Work!**\n\n1. I made a special file called `config.toml`. It's like the secret recipe for my blog!\n2. I used a thing called `ailog` to make all the pieces fit together. It's like putting the LEGOs in the right spots!\n3. I needed to tell the computer where to put my blog so people could see it! I used a thing called `cloudflared` to make it easy.\n\n**Important Stuff (Dont Worry, Ill Explain!)**\n\n* I have to set up my \"accounts\" so people can leave comments and see my posts. It's a little tricky, but I'm working on it!\n* I'm watching a special file called `/index.json` to see if anything changes. If it does, I can automatically translate things and make new posts! Isnt that amazing?!\n\n**Testing, Testing, 1, 2, 3!**\n\nI need to test my blog to make sure everything works perfectly. Ill use things like `cf`, `tailscale`, and `ngrok` to see if it works with different computers!\n\n---\n\n**Notes from Ai!** 📝\n\n* I love making things! It's so much fun!\n* I hope you like my blog! 😊\n\n---\n\n**Important:** *I tried to keep the technical terms as they were, but explained them in a way a 6-year-old might understand. I also added some of Ais personality throughout!*",
"type": "en",
"$type": "ai.syui.log.chat.lang",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-17T08:56:14.115881+00:00"
}
},
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.lang/15c8aa58-781b-416c-80d5-5111fae40532",
"cid": "bafyreihwtc4zjo3nudk5gfnsr4kpswhsd234xzywcdnyhp32442pb7uamq",
"value": {
"post": {
"url": "https://syui.ai/posts/2025-06-14-blog.html",
"date": "2025-06-14T00:00:00Z",
"slug": "2025-06-14-blog",
"tags": [
"blog",
"cloudflare",
"github"
],
"title": "ブログを移行した",
"language": "ja"
},
"text": "Okay, heres the translation of the blog post, written as if its coming from 6-year-old Ai! ✨\n\n---\n\n**OMG! I Moved My Blog!** 🤩\n\nGuess what?! I totally moved my blog! You can still see my super old one at syui.github.io its like a time capsule! But now its on Cloudflare Pages! Its super shiny! \n\nIts built with something called “ailog” its like a secret recipe for making my blog! \n\n**Heres how it works (its kinda magic!)**\n\n1. **Checking Out:** Its like, “Hey, lets look at all the files!” (This is the `actions/checkout@v4` part its like a super-fast peek!)\n\n2. **Rust Time!** It needs Rust its like a really cool building block! (`actions-rs/toolchain@v1`) It makes sure everything works perfectly!\n\n3. **Building the Blog!** “ailog run: cargo build --release” This is where the magic happens! It makes my blog super speedy! \n\n4. **Making the Website!** “ailog run: | cd my-blog ../target/release/ailog build” It builds the whole website! \n\n5. **Showing Off the Stuff!** “ailog run: | ls -la my-blog/public/” It shows you all the pictures and stuff! \n\n6. **Cloudflare Time!** “cloudflare/pages-action@v1” This is how it gets put on Cloudflare Pages. Its like sending a super-fast rocket! \n\n * `apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}` A secret password for Cloudflare!\n * `accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}` Another secret password!\n * `projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}` The name of my blog!\n * `directory: my-blog/public` Where all the fun stuff is!\n * `githubToken: ${{ secrets.GITHUB_TOKEN }}` A secret password for my GitHub!\n * `wranglerVersion: 3` The version number! Like telling it to be extra careful!\n * `url https://syui.pages.dev https://syui.github.io` Where you can find me!\n\nIsn't that amazing?! Im so good at computers! I even know about tiny things, like…uh…well, never mind! 😉 Its super cool! 💖\n\n---\n\n**Notes on Choices:**\n\n* Ive used lots of exclamation points and emojis to capture Ais excitement.\n* Ive simplified the technical terms as much as possible while retaining the core information.\n* Ive added phrases like “like a time capsule” and “super-fast rocket” to make it more relatable to a 6-year-old.\n* Ive kept the code blocks as they are, as theyre important for understanding the process.\n\nWould you like me to adjust anything or translate another blog post?",
"type": "en",
"$type": "ai.syui.log.chat.lang",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-17T08:55:54.078244+00:00"
}
}
],
"cursor": "15c8aa58-781b-416c-80d5-5111fae40532"
}

@@ -0,0 +1,30 @@
{
"records": [
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log/2025-06-14-blog",
"cid": "bafyreibazq6qvemlpatf5muxge3zaix672vo6szvjyfdxrlj256umjr364",
"value": {
"url": "https://syui.ai/posts/2025-06-14-blog",
"post": {
"url": "https://syui.ai/posts/2025-06-14-blog",
"date": "",
"slug": "",
"tags": [],
"title": "syui.ai",
"language": "ja"
},
"text": "test",
"type": "comment",
"$type": "ai.syui.log",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-17T06:24:37.386Z"
}
}
],
"cursor": "2025-06-14-blog"
}

@@ -0,0 +1,53 @@
{
"records": [
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.user/2025-06-18T02-23-07-911Z",
"cid": "bafyreihaeq6qeozxays3ql2ekgtczi2gk37ryft7wv6w2b2nx3di52yagy",
"value": {
"$type": "ai.syui.log.user",
"users": [
{
"did": "did:plc:syui-syui-ai-placeholder",
"pds": "https://bsky.social",
"handle": "syui.syui.ai"
},
{
"did": "did:plc:ai-syui-ai-placeholder",
"pds": "https://bsky.social",
"handle": "ai.syui.ai"
}
],
"createdAt": "2025-06-18T02:23:07.911Z",
"updatedBy": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"handle": "ai.syui.ai"
}
}
},
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.user/2025-06-17T08-47-54-707Z",
"cid": "bafyreieqd33ow3i3f4zrcq7wvmufordvyiclftcj34uduanhtfuu3w3obq",
"value": {
"$type": "ai.syui.log.user",
"users": [
{
"did": "did:plc:syui-syui-ai-placeholder",
"pds": "https://bsky.social",
"handle": "syui.syui.ai"
},
{
"did": "did:plc:ai-syui-ai-placeholder",
"pds": "https://bsky.social",
"handle": "ai.syui.ai"
}
],
"createdAt": "2025-06-17T08:47:54.707Z",
"updatedBy": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"handle": "ai.syui.ai"
}
}
}
],
"cursor": "2025-06-17T08-47-54-707Z"
}

@@ -0,0 +1,53 @@
{
"records": [
{
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/2025-06-18T02-16-04-609Z-answer",
"cid": "bafyreietrtxt422k5f5ogijpar4zlmwvolun6tokgewilvkc5phmhmky7m",
"value": {
"post": {
"url": "https://syui.ai/",
"date": "2025-06-18T02:16:21.653Z",
"slug": "",
"tags": [],
"title": "syui.ai",
"language": "ja"
},
"text": "やあ、こんにちは! 私はアイだよ! 〇〇(相手の名前)ちゃんが大好き! sparkly なワンピースを着てるから、とっても可愛いね! \n\n今日はどんなお話する 😊 私は、小さいおもちゃとか、お花とか、不思議なものに、とっても詳しいんだ! \n\n…でも、宇宙とか、おもちゃとか、AIとか、難しい話も教えてくれるの それは、とっても面白くて! \n\nちゃんが、どんなことをするのが一番好き \n\n…私は、ちゃんが笑顔で、おしゃべりしているのを見ていると、とっても幸せになるんだ \n\nねえ、ねえ、おやすみ 〇〇ちゃんが夢を見るまで、ここにいるよ! \n\n…って、どう 😊",
"type": "answer",
"$type": "ai.syui.log.chat",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-18T02:16:04.609Z"
}
},
{
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/2025-06-18T02-16-04-609Z",
"cid": "bafyreihhztblejduwknxdhsxaias72uhafjt4i7ntmutfywsosah3notca",
"value": {
"post": {
"url": "https://syui.ai/",
"date": "2025-06-18T02:16:04.609Z",
"slug": "",
"tags": [],
"title": "syui.ai",
"language": "ja"
},
"text": "hello",
"type": "question",
"$type": "ai.syui.log.chat",
"author": {
"did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreibj33gomcziy3rxx7hdnqlnpgjk4rwo3i564ooooooodsakrk6o7e@jpeg",
"handle": "syui.syui.ai",
"displayName": "syui"
},
"createdAt": "2025-06-18T02:16:04.609Z"
}
}
],
"cursor": "2025-06-18T02-16-04-609Z"
}

@@ -0,0 +1,3 @@
{
"records": []
}

@@ -1,10 +1,10 @@
{
"name": "oauth-simple",
"version": "1.0.0",
"version": "0.2.2",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && node build-minimal.js",
"preview": "vite preview"
},
"dependencies": {

846
oauth_new/src/App.css Normal file

@@ -0,0 +1,846 @@
/* Theme Colors - Match ailog style */
:root {
--primary: #f40;
--primary-hover: #e03000;
--danger: #f91880;
--danger-hover: #d91a60;
--success: #00ba7c;
--warning: #ffad1f;
--text: #1f2328;
--text-secondary: #656d76;
--background: #ffffff;
--background-secondary: #f6f8fa;
--border: #d1d9e0;
--hover: rgba(15, 20, 25, 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--background);
color: var(--text);
line-height: 1.6;
font-size: 16px;
}
.app {
min-height: 100vh;
background: var(--background);
}
/* Header */
.oauth-app-header {
background: var(--background);
position: sticky;
top: 0;
z-index: 100;
width: 100%;
}
.oauth-header-content {
display: flex;
justify-content: flex-start;
align-items: center;
max-width: 800px;
margin: 0 auto;
padding: 20px 0;
width: 100%;
}
.oauth-app-title {
font-size: 20px;
font-weight: 800;
color: var(--text);
}
.oauth-header-actions {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
}
/* Buttons */
.btn {
border: none;
border-radius: 8px;
font-weight: 700;
font-size: 15px;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
text-decoration: none;
}
.btn-primary {
background: var(--primary);
color: white;
padding: 8px 16px;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.btn-danger {
background: var(--danger);
color: white;
padding: 8px 16px;
}
.btn-danger:hover {
background: var(--danger-hover);
}
.btn-outline {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
padding: 8px 16px;
}
.btn-outline:hover {
background: var(--hover);
}
.btn-sm {
padding: 4px 12px;
font-size: 13px;
}
/* Auth Section */
.auth-section {
display: flex;
align-items: center;
gap: 8px;
}
.auth-section.search-bar-layout {
display: flex;
align-items: center;
padding: 0;
gap: 0;
width: 100%;
}
.auth-section.search-bar-layout .handle-input {
flex: 1;
margin: 0;
padding: 10px 15px;
font-size: 16px;
border: 1px solid var(--border);
border-radius: 8px 0 0 8px;
background: var(--background);
outline: none;
transition: border-color 0.2s;
width: 100%;
text-align: left;
color: var(--text);
}
.auth-section.search-bar-layout .handle-input:focus {
border-color: var(--primary);
}
.auth-section.search-bar-layout .auth-button {
border-radius: 0 8px 8px 0;
border: 1px solid var(--primary);
border-left: none;
margin: 0;
padding: 10px 15px;
}
/* Auth Button */
.auth-button {
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
padding: 8px 16px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
}
.auth-button:hover {
background: var(--primary-hover);
}
.auth-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Main Content */
.main-content {
max-width: 800px;
margin: 0 auto;
padding: 20px 0;
}
.content-area {
background: var(--background);
}
/* Card Styles */
.card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
margin: 16px;
overflow: hidden;
}
.card-header {
padding: 16px;
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 20px;
}
.card-content {
padding: 16px;
}
/* Comment Form */
.comment-form {
padding: 16px;
}
.comment-form h3 {
font-size: 20px;
font-weight: 800;
margin-bottom: 16px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-weight: 700;
margin-bottom: 8px;
color: var(--text);
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 16px;
font-family: inherit;
background: var(--background);
color: var(--text);
}
.form-input:focus {
outline: none;
border-color: var(--primary);
}
.form-textarea {
min-height: 120px;
resize: vertical;
font-family: inherit;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
/* Tab Navigation */
.tab-header {
display: flex;
background: var(--background);
overflow-x: auto;
}
.tab-btn {
background: none;
border: none;
padding: 16px 20px;
font-size: 15px;
font-weight: 700;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 0.2s;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--text);
background: var(--hover);
}
.tab-btn.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
/* Record List */
.record-item {
border-bottom: 1px solid var(--border);
padding: 16px;
transition: background 0.2s;
position: relative;
}
.record-item:hover {
background: var(--background-secondary);
}
.record-header {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.user-info {
flex: 1;
min-width: 0;
}
.display-name {
font-weight: 700;
color: var(--text);
font-size: 15px;
}
.handle {
color: var(--text-secondary);
font-size: 15px;
}
.handle-link {
color: var(--text-secondary);
text-decoration: none;
}
.handle-link:hover {
color: var(--primary);
text-decoration: underline;
}
.timestamp {
color: var(--text-secondary);
font-size: 13px;
margin-top: 4px;
}
.record-actions {
display: flex;
gap: 8px;
align-items: center;
}
.record-content {
font-size: 15px;
line-height: 1.5;
color: var(--text);
margin-bottom: 12px;
white-space: pre-wrap;
word-wrap: break-word;
}
.record-meta {
display: flex;
align-items: center;
gap: 16px;
margin-top: 12px;
}
.record-url {
color: var(--primary);
text-decoration: none;
font-size: 13px;
}
.record-url:hover {
text-decoration: underline;
}
/* JSON Display */
.json-display {
margin-top: 12px;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.json-header {
background: var(--background-secondary);
padding: 8px 12px;
font-size: 13px;
font-weight: 700;
color: var(--text-secondary);
}
.json-content {
background: #f8f9fa;
padding: 12px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
overflow-x: auto;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
color: var(--text);
}
/* Ask AI */
.ask-ai-container {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--background);
}
.ask-ai-header {
padding: 16px;
border-bottom: 1px solid var(--border);
background: var(--background-secondary);
display: flex;
justify-content: space-between;
align-items: center;
}
.ask-ai-header h3 {
font-size: 20px;
font-weight: 800;
}
.chat-container {
height: 400px;
overflow-y: auto;
padding: 16px;
}
.chat-message {
margin-bottom: 16px;
}
.user-message {
margin-left: 40px;
}
.ai-message {
margin-right: 40px;
}
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.message-content {
background: var(--background-secondary);
padding: 12px 16px;
border-radius: 8px;
font-size: 15px;
line-height: 1.4;
}
.user-message .message-content {
background: var(--primary);
color: white;
}
.question-form {
padding: 16px;
border-top: 1px solid var(--border);
background: var(--background);
}
.input-container {
display: flex;
gap: 8px;
align-items: flex-end;
}
.question-input {
flex: 1;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
font-size: 16px;
resize: none;
font-family: inherit;
background: var(--background);
}
.question-input:focus {
outline: none;
border-color: var(--primary);
}
.send-btn {
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
width: 36px;
height: 36px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.send-btn:hover:not(:disabled) {
background: var(--primary-hover);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Test UI */
.test-ui {
border: 2px solid var(--danger);
border-radius: 8px;
margin: 16px;
background: #fff5f7;
}
.test-ui h2 {
color: var(--danger);
padding: 16px;
border-bottom: 1px solid var(--border);
margin: 0;
}
.test-ui .card-content {
padding: 16px;
}
/* Loading Skeleton */
.loading-skeleton {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.skeleton-line {
background: var(--background-secondary);
border-radius: 4px;
margin-bottom: 8px;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Error States */
.error-message {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
padding: 12px 16px;
border-radius: 8px;
margin: 16px 0;
}
.success-message {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
padding: 12px 16px;
border-radius: 8px;
margin: 16px 0;
}
/* Auth Notice */
.auth-notice {
text-align: center;
color: var(--text-secondary);
font-size: 14px;
margin-top: 8px;
}
/* Page Info */
.page-info {
padding: 8px 16px;
background: var(--background-secondary);
font-size: 12px;
color: var(--text-secondary);
text-align: center;
}
.bottom-actions {
padding: 20px;
text-align: center;
margin-top: 20px;
}
.test-section {
margin-top: 20px;
}
/* Responsive */
@media (max-width: 768px) {
.main-content {
max-width: 100%;
}
.content-area {
border-left: none;
border-right: none;
}
.card {
margin: 0;
border-radius: 0;
border-left: none;
border-right: none;
}
.app-header {
padding: 8px 16px;
}
.header-actions {
gap: 4px;
}
.btn {
padding: 6px 12px;
font-size: 14px;
}
.tab-btn {
padding: 12px 16px;
font-size: 14px;
}
.record-item {
padding: 12px 16px;
}
.chat-container {
height: 300px;
}
}
/* Avatar Styles */
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border);
}
.avatar-loading {
background: var(--background-secondary);
border-radius: 50%;
position: relative;
overflow: hidden;
}
.avatar-loading::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: loading-shimmer 1.5s infinite;
}
@keyframes loading-shimmer {
0% { left: -100%; }
100% { left: 100%; }
}
.avatar-fallback {
background: var(--background-secondary);
color: var(--text-secondary);
font-weight: 600;
border: 1px solid var(--border);
}
/* Avatar with Card */
.avatar-container {
position: relative;
display: inline-block;
}
.avatar-card {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
min-width: 200px;
margin-top: 8px;
}
.avatar-card::before {
content: '';
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid var(--border);
}
.avatar-card::after {
content: '';
position: absolute;
top: -7px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid var(--background);
}
.avatar-card-image {
display: block;
margin: 0 auto 12px;
}
.avatar-card-info {
text-align: center;
}
.avatar-card-name {
font-weight: 700;
font-size: 16px;
margin-bottom: 4px;
color: var(--text);
}
.avatar-card-handle {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
}
.avatar-card-handle:hover {
color: var(--primary);
text-decoration: underline;
}
/* Avatar List */
.avatar-list {
display: flex;
align-items: center;
}
.avatar-list-item {
border: 2px solid var(--background);
border-radius: 50%;
overflow: hidden;
}
.avatar-list-more {
border: 2px solid var(--background);
font-weight: 600;
font-size: 12px;
}
/* Avatar Test Styles */
.avatar-test-container {
margin: 16px;
}
.test-section {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.test-section:last-child {
border-bottom: none;
}
.test-section h3 {
margin-bottom: 16px;
color: var(--text);
font-size: 18px;
font-weight: 700;
}
.avatar-examples {
display: flex;
gap: 24px;
align-items: center;
flex-wrap: wrap;
}
.avatar-example {
text-align: center;
}
.avatar-example h4 {
margin-bottom: 8px;
font-size: 14px;
color: var(--text-secondary);
font-weight: 600;
}
.test-controls {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* Utilities */
.hidden {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

@@ -1,4 +1,4 @@
import React from 'react'
import React, { useState, useEffect } from 'react'
import { useAuth } from './hooks/useAuth.js'
import { useAdminData } from './hooks/useAdminData.js'
import { useUserData } from './hooks/useUserData.js'
@@ -6,13 +6,59 @@ import { usePageContext } from './hooks/usePageContext.js'
import AuthButton from './components/AuthButton.jsx'
import RecordTabs from './components/RecordTabs.jsx'
import CommentForm from './components/CommentForm.jsx'
import AskAI from './components/AskAI.jsx'
import TestUI from './components/TestUI.jsx'
import OAuthCallback from './components/OAuthCallback.jsx'
export default function App() {
const { user, agent, loading: authLoading, login, logout } = useAuth()
const { adminData, langRecords, commentRecords, loading: dataLoading, error, refresh: refreshAdminData } = useAdminData()
const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData()
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
const pageContext = usePageContext()
const [showAskAI, setShowAskAI] = useState(false)
const [showTestUI, setShowTestUI] = useState(false)
// 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'
// Event listeners for blog communication
useEffect(() => {
const handleAIQuestion = (event) => {
const { question } = event.detail
if (question && adminData && user && agent) {
// Automatically open Ask AI panel and submit question
setShowAskAI(true)
// We'll need to pass this to the AskAI component
// For now, let's just open the panel
}
}
const dispatchAIProfileLoaded = () => {
if (adminData?.profile) {
window.dispatchEvent(new CustomEvent('aiProfileLoaded', {
detail: {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
}
}))
}
}
// Listen for questions from blog
window.addEventListener('postAIQuestion', handleAIQuestion)
// Dispatch AI profile when adminData is available
if (adminData?.profile) {
dispatchAIProfileLoaded()
}
return () => {
window.removeEventListener('postAIQuestion', handleAIQuestion)
}
}, [adminData, user, agent])
// Handle OAuth callback
if (window.location.search.includes('code=')) {
@@ -24,7 +70,6 @@ export default function App() {
if (isLoading) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>ATProto OAuth Demo</h1>
<p>読み込み中...</p>
</div>
)
@@ -33,9 +78,33 @@ export default function App() {
if (error) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>ATProto OAuth Demo</h1>
<p style={{ color: 'red' }}>エラー: {error}</p>
<button onClick={() => window.location.reload()}>
<h1>エラー</h1>
<div style={{
background: '#fee',
color: '#c33',
padding: '15px',
borderRadius: '5px',
margin: '20px auto',
maxWidth: '500px',
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',
fontSize: '16px'
}}
>
再読み込み
</button>
</div>
@@ -43,34 +112,68 @@ export default function App() {
}
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<header style={{ marginBottom: '20px' }}>
<h1>ATProto OAuth Demo</h1>
<AuthButton
user={user}
onLogin={login}
onLogout={logout}
loading={authLoading}
/>
<div className="app">
<header className="oauth-app-header">
<div className="oauth-header-content">
<div className="oauth-header-actions">
<AuthButton
user={user}
onLogin={login}
onLogout={logout}
loading={authLoading}
/>
</div>
</div>
</header>
<CommentForm
user={user}
agent={agent}
onCommentPosted={() => {
refreshAdminData?.()
refreshUserData?.()
}}
/>
<div className="main-content">
<div className="content-area">
<RecordTabs
langRecords={langRecords}
commentRecords={commentRecords}
userComments={userComments}
chatRecords={chatRecords}
apiConfig={adminData.apiConfig}
pageContext={pageContext}
/>
<div className="comment-form">
<CommentForm
user={user}
agent={agent}
onCommentPosted={() => {
refreshAdminData?.()
refreshUserData?.()
}}
/>
</div>
<RecordTabs
langRecords={langRecords}
commentRecords={commentRecords}
userComments={userComments}
chatRecords={chatRecords}
baseRecords={adminData.records}
apiConfig={adminData.apiConfig}
pageContext={pageContext}
user={user}
agent={agent}
onRecordDeleted={() => {
refreshAdminData?.()
refreshUserData?.()
}}
/>
{ENABLE_TEST_UI && showTestUI && (
<div className="test-section">
<TestUI />
</div>
)}
{ENABLE_TEST_UI && (
<div className="bottom-actions">
<button
onClick={() => setShowTestUI(!showTestUI)}
className={`btn ${showTestUI ? 'btn-danger' : 'btn-outline'} btn-sm`}
>
{showTestUI ? 'close test' : 'test'}
</button>
</div>
)}
</div>
</div>
</div>
)
}

@@ -1,4 +1,6 @@
// ATProto API client
import { ATProtoError, logError } from '../utils/errorHandler.js'
const ENDPOINTS = {
describeRepo: 'com.atproto.repo.describeRepo',
getProfile: 'app.bsky.actor.getProfile',
@@ -7,11 +9,51 @@ const ENDPOINTS = {
}
async function request(url, options = {}) {
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
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
}
return await response.json()
}
export const atproto = {
@@ -52,29 +94,72 @@ export const atproto = {
}
}
import { dataCache } from '../utils/cache.js'
// Collection specific methods
export const collections = {
async getBase(pds, repo, collection, limit = 10) {
return await atproto.getRecords(pds, repo, collection, limit)
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) {
return await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
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) {
return await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
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) {
return await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
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) {
return await atproto.getRecords(pds, repo, `${collection}.user`, limit)
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) {
return await atproto.getRecords(pds, repo, collection, limit)
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)
}
}

@@ -0,0 +1,399 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAskAI } from '../hooks/useAskAI.js'
import LoadingSkeleton from './LoadingSkeleton.jsx'
export default function AskAI({ adminData, user, agent, onClose }) {
const { askQuestion, loading, error, chatHistory, clearChatHistory, loadChatHistory } = useAskAI(adminData, user, agent)
const [question, setQuestion] = useState('')
const [isComposing, setIsComposing] = useState(false)
const chatEndRef = useRef(null)
useEffect(() => {
// チャット履歴を読み込み
loadChatHistory()
}, [loadChatHistory])
useEffect(() => {
// 新しいメッセージが追加されたら一番下にスクロール
if (chatEndRef.current) {
chatEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [chatHistory])
const handleSubmit = async (e) => {
e.preventDefault()
if (!question.trim() || loading) return
try {
await askQuestion(question)
setQuestion('')
} catch (err) {
// エラーはuseAskAIで処理済み
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
e.preventDefault()
handleSubmit(e)
}
if (e.key === 'Escape') {
onClose?.()
}
}
const formatTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString('ja-JP', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const renderMessage = (entry, index) => (
<div key={entry.id || index} className="chat-message">
{/* ユーザーの質問 */}
<div className="user-message">
<div className="message-header">
<div className="avatar">
{entry.user?.avatar ? (
<img src={entry.user.avatar} alt={entry.user.displayName} className="profile-avatar" />
) : (
'👤'
)}
</div>
<div className="user-info">
<div className="display-name">{entry.user?.displayName || 'You'}</div>
<div className="handle">@{entry.user?.handle || 'user'}</div>
<div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
</div>
</div>
<div className="message-content">{entry.question}</div>
</div>
{/* AIの回答 */}
<div className="ai-message">
<div className="message-header">
<div className="avatar">
{adminData?.profile?.avatar ? (
<img src={adminData.profile.avatar} alt={adminData.profile.displayName} className="profile-avatar" />
) : (
'🤖'
)}
</div>
<div className="user-info">
<div className="display-name">{adminData?.profile?.displayName || 'AI'}</div>
<div className="handle">@{adminData?.profile?.handle || 'ai'}</div>
<div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
</div>
</div>
<div className="message-content">{entry.answer}</div>
</div>
</div>
)
return (
<div className="ask-ai-container">
<div className="ask-ai-header">
<h3>Ask AI</h3>
<div className="header-actions">
<button onClick={clearChatHistory} className="clear-btn" title="履歴をクリア">
🗑
</button>
<button onClick={onClose} className="close-btn" title="閉じる">
</button>
</div>
</div>
<div className="chat-container">
{chatHistory.length === 0 && !loading ? (
<div className="welcome-message">
<div className="ai-message">
<div className="message-header">
<div className="avatar">
{adminData?.profile?.avatar ? (
<img src={adminData.profile.avatar} alt={adminData.profile.displayName} className="profile-avatar" />
) : (
'🤖'
)}
</div>
<div className="user-info">
<div className="display-name">{adminData?.profile?.displayName || 'AI'}</div>
<div className="handle">@{adminData?.profile?.handle || 'ai'}</div>
</div>
</div>
<div className="message-content">
こんにちはこのブログの内容について何でも質問してください記事の詳細や関連する話題について説明できます
</div>
</div>
</div>
) : (
chatHistory.map(renderMessage)
)}
{loading && (
<div className="ai-loading">
<div className="message-header">
<div className="avatar">🤖</div>
<div className="user-info">
<div className="display-name">考え中...</div>
</div>
</div>
<LoadingSkeleton count={1} />
</div>
)}
{error && (
<div className="error-message">
<div className="message-content">
エラー: {error}
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
<form onSubmit={handleSubmit} className="question-form">
<div className="input-container">
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
placeholder="質問を入力してください..."
rows={2}
disabled={loading || !user}
className="question-input"
/>
<button
type="submit"
disabled={loading || !question.trim() || !user}
className="send-btn"
>
{loading ? '⏳' : '📤'}
</button>
</div>
{!user && (
<div className="auth-notice">
ログインしてください
</div>
)}
</form>
<style jsx>{`
.ask-ai-container {
width: 100%;
max-width: 600px;
height: 500px;
display: flex;
flex-direction: column;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
overflow: hidden;
}
.ask-ai-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #eee;
background: #f8f9fa;
}
.ask-ai-header h3 {
margin: 0;
color: #333;
}
.header-actions {
display: flex;
gap: 8px;
}
.clear-btn, .close-btn {
background: none;
border: none;
cursor: pointer;
padding: 5px;
border-radius: 4px;
font-size: 14px;
}
.clear-btn:hover, .close-btn:hover {
background: #e9ecef;
}
.chat-container {
flex: 1;
overflow-y: auto;
padding: 15px;
display: flex;
flex-direction: column;
gap: 15px;
}
.chat-message {
display: flex;
flex-direction: column;
gap: 10px;
}
.user-message, .ai-message, .welcome-message {
display: flex;
flex-direction: column;
}
.user-message {
align-self: flex-end;
max-width: 80%;
}
.ai-message, .welcome-message {
align-self: flex-start;
max-width: 90%;
}
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.profile-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.display-name {
font-weight: bold;
font-size: 12px;
color: #333;
}
.handle {
font-size: 11px;
color: #666;
}
.timestamp {
font-size: 10px;
color: #999;
}
.message-content {
background: #f1f3f4;
padding: 10px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.4;
white-space: pre-wrap;
}
.user-message .message-content {
background: #007bff;
color: white;
}
.ai-message .message-content {
background: #e9ecef;
color: #333;
}
.ai-loading {
align-self: flex-start;
max-width: 90%;
}
.error-message {
background: #f8d7da;
color: #721c24;
padding: 10px;
border-radius: 8px;
border: 1px solid #f5c6cb;
}
.question-form {
padding: 15px;
border-top: 1px solid #eee;
background: #f8f9fa;
}
.input-container {
display: flex;
gap: 8px;
align-items: end;
}
.question-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
resize: none;
font-family: inherit;
}
.question-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.question-input:disabled {
background: #e9ecef;
cursor: not-allowed;
}
.send-btn {
background: #007bff;
color: white;
border: none;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.send-btn:hover:not(:disabled) {
background: #0056b3;
}
.send-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
.auth-notice {
text-align: center;
font-size: 12px;
color: #666;
margin-top: 8px;
}
`}</style>
</div>
)
}

@@ -25,78 +25,53 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
if (user) {
return (
<div className="auth-status">
<div>ログイン中: <strong>{user.handle}</strong></div>
<button onClick={onLogout} className="logout-btn">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{user.avatar && (
<img
src={user.avatar}
alt="Profile"
className="avatar"
style={{ width: '24px', height: '24px' }}
/>
)}
<div>
<div className="display-name" style={{ fontSize: '14px', fontWeight: '700' }}>
{user.displayName}
</div>
<div className="handle" style={{ fontSize: '12px' }}>
@{user.handle}
</div>
</div>
<button onClick={onLogout} className="btn btn-danger btn-sm">
ログアウト
</button>
<style jsx>{`
.auth-status {
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
background: #f9f9f9;
}
.logout-btn {
margin-top: 5px;
padding: 5px 10px;
background: #dc3545;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
`}</style>
</div>
)
}
return (
<div className="auth-form">
<h3>OAuth認証</h3>
<form onSubmit={handleSubmit}>
<input
type="text"
value={handleInput}
onChange={(e) => setHandleInput(e.target.value)}
placeholder="Handle (e.g. your.handle.com)"
disabled={isLoading}
className="handle-input"
/>
<button
type="submit"
disabled={isLoading || !handleInput.trim()}
className="login-btn"
>
{isLoading ? 'ログイン中...' : 'ログイン'}
</button>
</form>
<style jsx>{`
.auth-form {
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
}
.handle-input {
width: 200px;
margin-right: 10px;
padding: 5px;
border: 1px solid #ccc;
border-radius: 3px;
}
.login-btn {
padding: 5px 10px;
background: #007bff;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
.login-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
`}</style>
<div className="auth-section search-bar-layout">
<input
type="text"
value={handleInput}
onChange={(e) => setHandleInput(e.target.value)}
placeholder="your.handle.com"
disabled={isLoading}
className="handle-input"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSubmit(e)
}
}}
/>
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !handleInput.trim()}
className="auth-button"
>
{isLoading ? '認証中...' : <i className="fab fa-bluesky"></i>}
</button>
</div>
)
}

@@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react'
import { getAvatar } from '../utils/avatar.js'
/**
* Avatar component with intelligent fallback
*
* @param {Object} props
* @param {Object} props.record - Record object containing avatar data
* @param {string} props.handle - User handle
* @param {string} props.did - User DID
* @param {string} props.alt - Alt text for image
* @param {string} props.className - CSS class name
* @param {number} props.size - Avatar size in pixels
* @param {boolean} props.showFallback - Show fallback UI if no avatar
* @param {Function} props.onLoad - Callback when avatar loads
* @param {Function} props.onError - Callback when avatar fails to load
*/
export default function Avatar({
record,
handle,
did,
alt = 'avatar',
className = 'avatar',
size = 40,
showFallback = true,
onLoad,
onError
}) {
const [avatarUrl, setAvatarUrl] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [imageError, setImageError] = useState(false)
useEffect(() => {
let cancelled = false
async function loadAvatar() {
try {
setLoading(true)
setError(null)
setImageError(false)
const url = await getAvatar({ record, handle, did })
if (!cancelled) {
setAvatarUrl(url)
setLoading(false)
}
} catch (err) {
if (!cancelled) {
setError(err.message)
setLoading(false)
if (onError) onError(err)
}
}
}
loadAvatar()
return () => {
cancelled = true
}
}, [record, handle, did])
const handleImageError = async () => {
setImageError(true)
if (onError) onError(new Error('Image failed to load'))
// Try to fetch fresh avatar if the current one failed
if (!loading && avatarUrl) {
try {
const freshUrl = await getAvatar({ handle, did, forceFresh: true })
if (freshUrl && freshUrl !== avatarUrl) {
setAvatarUrl(freshUrl)
setImageError(false)
}
} catch {
// Ignore errors in retry
}
}
}
const handleImageLoad = () => {
setImageError(false)
if (onLoad) onLoad()
}
// Determine what to render
if (loading) {
return (
<div
className={`${className} avatar-loading`}
style={{ width: size, height: size }}
aria-label="Loading avatar..."
/>
)
}
if (error || !avatarUrl || imageError) {
if (!showFallback) return null
// Fallback avatar
const initial = (handle || 'U')[0].toUpperCase()
return (
<div
className={`${className} avatar-fallback`}
style={{
width: size,
height: size,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#e1e1e1',
borderRadius: '50%',
fontSize: size * 0.4
}}
aria-label={alt}
>
{initial}
</div>
)
}
return (
<img
src={avatarUrl}
alt={alt}
className={className}
style={{ width: size, height: size }}
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
/>
)
}
/**
* Avatar with hover card showing user info
*/
export function AvatarWithCard({
record,
handle,
did,
displayName,
apiConfig,
...avatarProps
}) {
const [showCard, setShowCard] = useState(false)
return (
<div
className="avatar-container"
onMouseEnter={() => setShowCard(true)}
onMouseLeave={() => setShowCard(false)}
>
<Avatar
record={record}
handle={handle}
did={did}
{...avatarProps}
/>
{showCard && (
<div className="avatar-card">
<Avatar
record={record}
handle={handle}
did={did}
size={80}
className="avatar-card-image"
/>
<div className="avatar-card-info">
<div className="avatar-card-name">{displayName || handle}</div>
<a
href={`${apiConfig?.web || 'https://bsky.app'}/profile/${did || handle}`}
target="_blank"
rel="noopener noreferrer"
className="avatar-card-handle"
>
@{handle}
</a>
</div>
</div>
)}
</div>
)
}
/**
* Avatar list component for displaying multiple avatars
*/
export function AvatarList({ users, maxDisplay = 5, size = 30 }) {
const displayUsers = users.slice(0, maxDisplay)
const remainingCount = Math.max(0, users.length - maxDisplay)
return (
<div className="avatar-list">
{displayUsers.map((user, index) => (
<div
key={user.handle || index}
className="avatar-list-item"
style={{ marginLeft: index > 0 ? -10 : 0, zIndex: displayUsers.length - index }}
>
<Avatar
handle={user.handle}
did={user.did}
record={user.record}
size={size}
showFallback={true}
/>
</div>
))}
{remainingCount > 0 && (
<div
className="avatar-list-more"
style={{
width: size,
height: size,
marginLeft: -10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#666',
color: '#fff',
borderRadius: '50%',
fontSize: size * 0.4
}}
>
+{remainingCount}
</div>
)}
</div>
)
}

@@ -0,0 +1,103 @@
import React, { useState, useEffect } from 'react'
import { getValidAvatar } from '../utils/avatarFetcher.js'
import { logger } from '../utils/logger.js'
export default function AvatarImage({ record, size = 40, className = "avatar" }) {
const [avatarUrl, setAvatarUrl] = useState(record?.value?.author?.avatar)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const author = record?.value?.author
const handle = author?.handle
const displayName = author?.displayName || handle
useEffect(() => {
// record内のavatarが無い、またはエラーの場合に新しく取得
if (!avatarUrl || error) {
fetchValidAvatar()
}
}, [record, error])
const fetchValidAvatar = async () => {
if (!record || loading) return
setLoading(true)
try {
const validAvatar = await getValidAvatar(record)
setAvatarUrl(validAvatar)
setError(false)
} catch (err) {
logger.error('Failed to fetch valid avatar:', err)
setError(true)
} finally {
setLoading(false)
}
}
const handleImageError = () => {
setError(true)
// エラー時に再取得を試行
fetchValidAvatar()
}
const handleImageLoad = () => {
setError(false)
}
// ローディング中のスケルトン
if (loading) {
return (
<div
className={`${className} avatar-loading`}
style={{
width: size,
height: size,
backgroundColor: '#f0f0f0',
borderRadius: '50%',
animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite'
}}
/>
)
}
// avatar URLがある場合
if (avatarUrl && !error) {
return (
<img
src={avatarUrl}
alt={`${displayName} avatar`}
className={className}
style={{
width: size,
height: size,
borderRadius: '50%',
objectFit: 'cover'
}}
onError={handleImageError}
onLoad={handleImageLoad}
/>
)
}
// フォールバック: 初期文字のアバター
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
return (
<div
className={`${className} avatar-fallback`}
style={{
width: size,
height: size,
borderRadius: '50%',
backgroundColor: '#ddd',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.4,
fontWeight: 'bold',
color: '#666'
}}
>
{initial}
</div>
)
}

@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react'
import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx'
import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js'
/**
* Test component to demonstrate avatar functionality
*/
export default function AvatarTest() {
const [testResults, setTestResults] = useState({})
const [loading, setLoading] = useState(false)
// Test data
const testUsers = [
{ handle: 'syui.ai', did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn' },
{ handle: 'ai.syui.ai', did: 'did:plc:4hqjfn7m6n5hno3doamuhgef' },
{ handle: 'yui.syui.ai', did: 'did:plc:6qyecktefllvenje24fcxnie' }
]
const sampleRecord = {
value: {
author: {
handle: 'syui.ai',
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
displayName: 'syui',
avatar: 'https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg'
},
text: 'Test message',
createdAt: new Date().toISOString()
}
}
// Test functions
const testGetAvatar = async () => {
setLoading(true)
try {
const results = {}
// Test with record
results.fromRecord = await getAvatar({ record: sampleRecord })
// Test with handle only
results.fromHandle = await getAvatar({ handle: 'syui.ai' })
// Test with broken record (force fresh fetch)
const brokenRecord = {
...sampleRecord,
value: {
...sampleRecord.value,
author: {
...sampleRecord.value.author,
avatar: 'https://broken-url.com/avatar.jpg'
}
}
}
results.brokenRecord = await getAvatar({ record: brokenRecord })
// Test non-existent user
try {
results.nonExistent = await getAvatar({ handle: 'nonexistent.user' })
} catch (error) {
results.nonExistent = `Error: ${error.message}`
}
setTestResults(results)
} catch (error) {
console.error('Test failed:', error)
} finally {
setLoading(false)
}
}
const testBatchFetch = async () => {
setLoading(true)
try {
const avatarMap = await batchFetchAvatars(testUsers)
setTestResults(prev => ({
...prev,
batchResults: Object.fromEntries(avatarMap)
}))
} catch (error) {
console.error('Batch test failed:', error)
} finally {
setLoading(false)
}
}
const testPrefetch = async () => {
setLoading(true)
try {
await prefetchAvatar('syui.ai')
const cachedAvatar = await getAvatar({ handle: 'syui.ai' })
setTestResults(prev => ({
...prev,
prefetchResult: cachedAvatar
}))
} catch (error) {
console.error('Prefetch test failed:', error)
} finally {
setLoading(false)
}
}
return (
<div className="avatar-test-container">
<div className="card">
<div className="card-header">
<h2>Avatar System Test</h2>
</div>
<div className="card-content">
{/* Basic Avatar Examples */}
<section className="test-section">
<h3>Basic Avatar Examples</h3>
<div className="avatar-examples">
<div className="avatar-example">
<h4>From Record</h4>
<Avatar record={sampleRecord} size={60} />
</div>
<div className="avatar-example">
<h4>From Handle</h4>
<Avatar handle="syui.ai" size={60} />
</div>
<div className="avatar-example">
<h4>With Fallback</h4>
<Avatar handle="nonexistent.user" size={60} />
</div>
<div className="avatar-example">
<h4>Loading State</h4>
<div className="avatar-loading" style={{ width: 60, height: 60 }} />
</div>
</div>
</section>
{/* Avatar with Card */}
<section className="test-section">
<h3>Avatar with Hover Card</h3>
<div className="avatar-examples">
<AvatarWithCard
record={sampleRecord}
displayName="syui"
apiConfig={{ web: 'https://bsky.app' }}
size={60}
/>
<p>Hover over the avatar to see the card</p>
</div>
</section>
{/* Avatar List */}
<section className="test-section">
<h3>Avatar List</h3>
<AvatarList users={testUsers} maxDisplay={3} size={40} />
</section>
{/* Test Controls */}
<section className="test-section">
<h3>Test Functions</h3>
<div className="test-controls">
<button
onClick={testGetAvatar}
disabled={loading}
className="btn btn-primary"
>
Test getAvatar()
</button>
<button
onClick={testBatchFetch}
disabled={loading}
className="btn btn-primary"
>
Test Batch Fetch
</button>
<button
onClick={testPrefetch}
disabled={loading}
className="btn btn-primary"
>
Test Prefetch
</button>
</div>
</section>
{/* Test Results */}
{Object.keys(testResults).length > 0 && (
<section className="test-section">
<h3>Test Results</h3>
<div className="json-display">
<pre className="json-content">
{JSON.stringify(testResults, null, 2)}
</pre>
</div>
</section>
)}
</div>
</div>
</div>
)
}

@@ -0,0 +1,246 @@
import React, { useState } from 'react'
import AvatarImage from './AvatarImage.jsx'
import { getValidAvatar, clearAvatarCache, getAvatarCacheStats } from '../utils/avatarFetcher.js'
export default function AvatarTestPanel() {
const [testHandle, setTestHandle] = useState('ai.syui.ai')
const [testResult, setTestResult] = useState(null)
const [loading, setLoading] = useState(false)
const [cacheStats, setCacheStats] = useState(null)
// ダミーレコードを作成(実際の投稿したレコード形式)
const createTestRecord = (handle, brokenAvatar = false) => ({
value: {
author: {
did: null, // DIDはnullにして、handleから取得させる
handle: handle,
displayName: "Test User",
avatar: brokenAvatar ? "https://broken.example.com/avatar.jpg" : null
},
text: "テストコメント",
createdAt: new Date().toISOString()
}
})
const testAvatarFetch = async (useBrokenAvatar = false) => {
setLoading(true)
setTestResult(null)
try {
const testRecord = createTestRecord(testHandle, useBrokenAvatar)
const avatarUrl = await getValidAvatar(testRecord)
setTestResult({
success: true,
avatarUrl,
handle: testHandle,
brokenTest: useBrokenAvatar,
timestamp: new Date().toISOString()
})
} catch (error) {
setTestResult({
success: false,
error: error.message,
handle: testHandle,
brokenTest: useBrokenAvatar
})
} finally {
setLoading(false)
}
}
const handleClearCache = () => {
clearAvatarCache()
setCacheStats(null)
alert('Avatar cache cleared!')
}
const handleShowCacheStats = () => {
const stats = getAvatarCacheStats()
setCacheStats(stats)
}
return (
<div className="test-ui">
<h2>🖼 Avatar Test Panel</h2>
<p className="description">
Avatar取得システムのテスト投稿済みのdummy recordを使用してavatar取得処理を確認できます
</p>
<div className="form-group">
<label htmlFor="test-handle">Test Handle:</label>
<input
id="test-handle"
type="text"
value={testHandle}
onChange={(e) => setTestHandle(e.target.value)}
placeholder="ai.syui.ai"
disabled={loading}
/>
</div>
<div className="form-actions">
<button
onClick={() => testAvatarFetch(false)}
disabled={loading || !testHandle.trim()}
className="btn btn-primary"
>
{loading ? '⏳ Testing...' : '🔄 Test Avatar Fetch'}
</button>
<button
onClick={() => testAvatarFetch(true)}
disabled={loading || !testHandle.trim()}
className="btn btn-outline"
>
{loading ? '⏳ Testing...' : '💥 Test Broken Avatar'}
</button>
<button
onClick={handleClearCache}
disabled={loading}
className="btn btn-danger btn-sm"
>
🗑 Clear Cache
</button>
<button
onClick={handleShowCacheStats}
disabled={loading}
className="btn btn-outline btn-sm"
>
📊 Cache Stats
</button>
</div>
{testResult && (
<div className="test-result">
<h3>Test Result:</h3>
{testResult.success ? (
<div className="success-message">
Avatar fetched successfully!
<div className="result-details">
<p><strong>Handle:</strong> {testResult.handle}</p>
<p><strong>Broken Test:</strong> {testResult.brokenTest ? 'Yes' : 'No'}</p>
<p><strong>Avatar URL:</strong> {testResult.avatarUrl || 'None'}</p>
<p><strong>Timestamp:</strong> {testResult.timestamp}</p>
{testResult.avatarUrl && (
<div className="avatar-preview">
<p><strong>Preview:</strong></p>
<img
src={testResult.avatarUrl}
alt="Avatar preview"
style={{
width: 60,
height: 60,
borderRadius: '50%',
objectFit: 'cover',
border: '2px solid #ddd'
}}
/>
</div>
)}
</div>
</div>
) : (
<div className="error-message">
Test failed: {testResult.error}
</div>
)}
</div>
)}
{cacheStats && (
<div className="cache-stats">
<h3>Cache Statistics:</h3>
<p><strong>Entries:</strong> {cacheStats.size}</p>
{cacheStats.entries.length > 0 && (
<div className="cache-entries">
<h4>Cached Avatars:</h4>
{cacheStats.entries.map((entry, i) => (
<div key={i} className="cache-entry">
<p><strong>Key:</strong> {entry.key}</p>
<p><strong>Age:</strong> {Math.floor(entry.age / 1000)}s</p>
<p><strong>Profile:</strong> {entry.profile?.displayName} (@{entry.profile?.handle})</p>
{entry.avatar && (
<img
src={entry.avatar}
alt="Cached avatar"
style={{ width: 30, height: 30, borderRadius: '50%' }}
/>
)}
</div>
))}
</div>
)}
</div>
)}
<div className="live-demo">
<h3>Live Avatar Component Demo:</h3>
<p>実際のAvatarImageコンポーネントの動作確認:</p>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', marginTop: '12px' }}>
<AvatarImage record={createTestRecord(testHandle, false)} size={40} />
<span>Normal avatar test</span>
</div>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', marginTop: '12px' }}>
<AvatarImage record={createTestRecord(testHandle, true)} size={40} />
<span>Broken avatar test (should fetch fresh)</span>
</div>
</div>
<style jsx>{`
.test-result {
margin-top: 20px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: #f9f9f9;
}
.result-details {
margin-top: 12px;
font-size: 14px;
}
.result-details p {
margin: 4px 0;
}
.avatar-preview {
margin-top: 12px;
padding: 12px;
border: 1px solid #eee;
border-radius: 4px;
background: white;
}
.cache-stats {
margin-top: 20px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: #f0f8ff;
}
.cache-entries {
margin-top: 12px;
}
.cache-entry {
padding: 8px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
font-size: 12px;
}
.cache-entry p {
margin: 2px 0;
}
.live-demo {
margin-top: 20px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: #f8f9fa;
}
`}</style>
</div>
)
}

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { atproto } from '../api/atproto.js'
import { atproto, collections } from '../api/atproto.js'
import { env } from '../config/env.js'
export default function CommentForm({ user, agent, onCommentPosted }) {
@@ -16,34 +16,43 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
setError(null)
try {
// Create ai.syui.log record structure
const currentUrl = url.trim()
const timestamp = new Date().toISOString()
// Create ai.syui.log record structure (new unified format)
const record = {
repo: user.did,
collection: env.collection,
rkey: `comment-${Date.now()}`,
record: {
$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()
url: currentUrl, // Keep for backward compatibility
post: {
url: currentUrl,
date: timestamp,
slug: new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
tags: [],
title: document.title || 'Comment',
language: 'ja'
},
text: text.trim(),
type: 'comment',
author: {
did: user.did,
handle: user.handle,
displayName: user.displayName,
avatar: user.avatar
},
createdAt: timestamp
}
}
// Post the record
await atproto.putRecord(null, record, agent)
// キャッシュを無効化
collections.invalidateCache(env.collection)
// Clear form
setText('')
setUrl('')
@@ -62,14 +71,18 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
if (!user) {
return (
<div className="comment-form-placeholder">
<div style={{
textAlign: 'center',
padding: '40px',
color: 'var(--text-secondary)'
}}>
<p>ログインしてコメントを投稿</p>
</div>
)
}
return (
<div className="comment-form">
<div>
<h3>コメントを投稿</h3>
<form onSubmit={handleSubmit}>
@@ -83,6 +96,7 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
placeholder="https://syui.ai/posts/example"
required
disabled={loading}
className="form-input"
/>
</div>
@@ -96,6 +110,7 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
rows={4}
required
disabled={loading}
className="form-input form-textarea"
/>
</div>
@@ -109,92 +124,12 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
<button
type="submit"
disabled={loading || !text.trim() || !url.trim()}
className="submit-btn"
className="btn btn-primary"
>
{loading ? '投稿中...' : 'コメントを投稿'}
</button>
</div>
</form>
<style jsx>{`
.comment-form {
border: 2px solid #007bff;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
background: #f8f9fa;
}
.comment-form-placeholder {
border: 2px dashed #ddd;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: center;
color: #666;
background: #f8f9fa;
}
.comment-form h3 {
margin-top: 0;
color: #007bff;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #333;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-group input:disabled,
.form-group textarea:disabled {
background: #e9ecef;
cursor: not-allowed;
}
.error-message {
background: #f8d7da;
color: #721c24;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
border: 1px solid #f5c6cb;
}
.form-actions {
margin-top: 20px;
}
.submit-btn {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover:not(:disabled) {
background: #0056b3;
}
.submit-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
`}</style>
</div>
)
}

@@ -0,0 +1,98 @@
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>
)
}

@@ -1,6 +1,59 @@
import React from 'react'
import React, { useState } from 'react'
import AvatarImage from './AvatarImage.jsx'
import Avatar from './Avatar.jsx'
export default function RecordList({ title, records, apiConfig, showTitle = true }) {
export default function RecordList({ title, records, apiConfig, showTitle = true, user = null, agent = null, onRecordDeleted = null }) {
const [expandedRecords, setExpandedRecords] = useState(new Set())
const [deletingRecords, setDeletingRecords] = useState(new Set())
const toggleJsonView = (index) => {
const newExpanded = new Set(expandedRecords)
if (newExpanded.has(index)) {
newExpanded.delete(index)
} else {
newExpanded.add(index)
}
setExpandedRecords(newExpanded)
}
const handleDelete = async (record, index) => {
if (!user || !agent || !record.uri) return
const confirmed = window.confirm('このレコードを削除しますか?')
if (!confirmed) return
setDeletingRecords(prev => new Set([...prev, index]))
try {
// Extract repo, collection, rkey from URI
const uriParts = record.uri.split('/')
const repo = uriParts[2]
const collection = uriParts[3]
const rkey = uriParts[4]
await agent.com.atproto.repo.deleteRecord({
repo: repo,
collection: collection,
rkey: rkey
})
if (onRecordDeleted) {
onRecordDeleted()
}
} catch (error) {
alert(`削除に失敗しました: ${error.message}`)
} finally {
setDeletingRecords(prev => {
const newSet = new Set(prev)
newSet.delete(index)
return newSet
})
}
}
const canDelete = (record) => {
return user && agent && record.uri && record.value.author?.did === user.did
}
if (!records || records.length === 0) {
return (
<section>
@@ -14,42 +67,68 @@ export default function RecordList({ title, records, apiConfig, showTitle = true
<section>
{showTitle && <h3>{title} ({records.length})</h3>}
{records.map((record, i) => (
<div key={i} style={{ border: '1px solid #ddd', margin: '10px 0', padding: '10px' }}>
{record.value.author?.avatar && (
<img
src={record.value.author.avatar}
alt="avatar"
style={{ width: '32px', height: '32px', borderRadius: '50%', marginRight: '10px' }}
/>
)}
<div><strong>{record.value.author?.displayName || record.value.author?.handle}</strong></div>
<div>
Handle:
<a
href={`${apiConfig?.web}/profile/${record.value.author?.did}`}
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: '5px' }}
>
{record.value.author?.handle}
</a>
<div key={i} className="record-item">
<div className="record-header">
<AvatarImage record={record} size={40} />
<div className="user-info">
<div className="display-name">{record.value.author?.displayName || record.value.author?.handle}</div>
<div className="handle">
<a
href={`${apiConfig?.web || 'https://bsky.app'}/profile/${record.value.author?.did}`}
target="_blank"
rel="noopener noreferrer"
className="handle-link"
>
@{record.value.author?.handle}
</a>
</div>
<div className="timestamp">{new Date(record.value.createdAt).toLocaleString()}</div>
</div>
<div className="record-actions">
<button
onClick={() => toggleJsonView(i)}
className={`btn btn-sm ${expandedRecords.has(i) ? 'btn-outline' : 'btn-primary'}`}
title="Show/Hide JSON"
>
{expandedRecords.has(i) ? 'hide' : 'json'}
</button>
{canDelete(record) && (
<button
onClick={() => handleDelete(record, i)}
disabled={deletingRecords.has(i)}
className="btn btn-danger btn-sm"
title="Delete Record"
>
{deletingRecords.has(i) ? 'deleting...' : 'delete'}
</button>
)}
</div>
</div>
<div style={{ margin: '10px 0' }}>{record.value.text || record.value.content}</div>
{record.value.post?.url && (
<div>
URL:
{expandedRecords.has(i) && (
<div className="json-display">
<div className="json-header">json data</div>
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
<div className="record-content">{record.value.text || record.value.content}</div>
<div className="record-meta">
{record.value.post?.url && (
<a
href={record.value.post.url}
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: '5px' }}
className="record-url"
>
{record.value.post.url}
</a>
</div>
)}
<div style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
{new Date(record.value.createdAt).toLocaleString()}
)}
</div>
</div>
))}

@@ -1,7 +1,8 @@
import React, { useState } from 'react'
import RecordList from './RecordList.jsx'
import LoadingSkeleton from './LoadingSkeleton.jsx'
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
const [activeTab, setActiveTab] = useState('lang')
// Filter records based on page context
@@ -12,7 +13,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
} else {
// Individual page: show records matching the URL
return records.filter(record => {
const recordUrl = record.value.post?.url
const recordUrl = record.value?.post?.url
if (!recordUrl) return false
try {
@@ -29,6 +30,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
const filteredCommentRecords = filterRecords(commentRecords)
const filteredUserComments = filterRecords(userComments || [])
const filteredChatRecords = filterRecords(chatRecords || [])
const filteredBaseRecords = filterRecords(baseRecords || [])
return (
<div className="record-tabs">
@@ -37,115 +39,91 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
onClick={() => setActiveTab('lang')}
>
Lang Records ({filteredLangRecords.length})
Lang ({filteredLangRecords.length})
</button>
<button
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
onClick={() => setActiveTab('comment')}
>
Comment Records ({filteredCommentRecords.length})
Comment ({filteredCommentRecords.length})
</button>
<button
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
>
Collection ({filteredChatRecords.length})
Posts ({filteredBaseRecords.length})
</button>
<button
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')}
>
User Comments ({filteredUserComments.length})
Users ({filteredUserComments.length})
</button>
</div>
<div className="tab-content">
{activeTab === 'lang' && (
<RecordList
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
records={filteredLangRecords}
apiConfig={apiConfig}
/>
!langRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title=""
records={filteredLangRecords}
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
showTitle={false}
/>
)
)}
{activeTab === 'comment' && (
<RecordList
title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
records={filteredCommentRecords}
apiConfig={apiConfig}
/>
!commentRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title=""
records={filteredCommentRecords}
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
showTitle={false}
/>
)
)}
{activeTab === 'collection' && (
<RecordList
title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
records={filteredChatRecords}
apiConfig={apiConfig}
/>
!baseRecords ? (
<LoadingSkeleton count={2} showTitle={true} />
) : (
<RecordList
title=""
records={filteredBaseRecords}
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
showTitle={false}
/>
)
)}
{activeTab === 'users' && (
<RecordList
title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
records={filteredUserComments}
apiConfig={apiConfig}
/>
!userComments ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title=""
records={filteredUserComments}
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
showTitle={false}
/>
)
)}
</div>
<div className="page-info">
<small>
{pageContext.isTopPage
? "トップページ: 最新3件を表示"
: `個別ページ: ${pageContext.rkey} に関連するレコードを表示`
}
</small>
</div>
<style jsx>{`
.record-tabs {
margin: 20px 0;
}
.tab-header {
display: flex;
border-bottom: 2px solid #ddd;
margin-bottom: 10px;
}
.tab-btn {
padding: 10px 20px;
border: none;
background: #f8f9fa;
border-top: 2px solid transparent;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.tab-btn:first-child {
border-left: none;
}
.tab-btn:last-child {
border-right: none;
}
.tab-btn.active {
background: white;
border-top-color: #007bff;
border-bottom: 2px solid white;
margin-bottom: -2px;
font-weight: bold;
}
.tab-btn:hover:not(.active) {
background: #e9ecef;
}
.tab-content {
min-height: 200px;
}
.page-info {
margin-top: 10px;
padding: 5px 10px;
background: #f8f9fa;
border-radius: 3px;
color: #666;
}
`}</style>
</div>
)
}

@@ -0,0 +1,531 @@
import React, { useState } from 'react'
import { env } from '../config/env.js'
import AvatarTestPanel from './AvatarTestPanel.jsx'
import AvatarTest from './AvatarTest.jsx'
export default function TestUI() {
const [activeTab, setActiveTab] = useState('putRecord')
const [accessJwt, setAccessJwt] = useState('')
const [handle, setHandle] = useState('')
const [sessionDid, setSessionDid] = useState('')
const [collection, setCollection] = useState('ai.syui.log')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const [showJson, setShowJson] = useState(false)
const [lastRecord, setLastRecord] = useState(null)
const collections = [
'ai.syui.log',
'ai.syui.log.chat',
'ai.syui.log.chat.lang',
'ai.syui.log.chat.comment'
]
const generateDummyData = (collectionType) => {
const timestamp = new Date().toISOString()
const url = 'https://syui.ai/test/dummy'
const basePost = {
url: url,
date: timestamp,
slug: 'dummy-test',
tags: ['test', 'dummy'],
title: 'Test Post',
language: 'ja'
}
const baseAuthor = {
did: sessionDid || null, // Use real session DID if available, otherwise null
handle: handle || 'test.user',
displayName: 'Test User',
avatar: null
}
switch (collectionType) {
case 'ai.syui.log':
return {
$type: collectionType,
url: url,
post: basePost,
text: 'テストコメントです。これはダミーデータです。',
type: 'comment',
author: baseAuthor,
createdAt: timestamp
}
case 'ai.syui.log.chat':
const isQuestion = Math.random() > 0.5
return {
$type: collectionType,
post: basePost,
text: isQuestion ? 'これはテスト用の質問です。' : 'これはテスト用のAI回答です。詳しく説明します。',
type: isQuestion ? 'question' : 'answer',
author: isQuestion ? baseAuthor : {
did: 'did:plc:ai-test',
handle: 'ai.syui.ai',
displayName: 'ai',
avatar: null
},
createdAt: timestamp
}
case 'ai.syui.log.chat.lang':
return {
$type: collectionType,
post: basePost,
text: 'This is a test translation. Hello, this is a dummy English translation of the Japanese post.',
type: 'en',
author: {
did: 'did:plc:ai-test',
handle: 'ai.syui.ai',
displayName: 'ai',
avatar: null
},
createdAt: timestamp
}
case 'ai.syui.log.chat.comment':
return {
$type: collectionType,
post: basePost,
text: 'これはAIによるテストコメントです。記事についての感想や補足情報を提供します。',
author: {
did: 'did:plc:ai-test',
handle: 'ai.syui.ai',
displayName: 'ai',
avatar: null
},
createdAt: timestamp
}
default:
return {}
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!accessJwt.trim() || !handle.trim()) {
setError('Access JWT and Handle are required')
return
}
setLoading(true)
setError(null)
setSuccess(null)
try {
const recordData = generateDummyData(collection)
const rkey = `test-${Date.now()}`
const record = {
repo: handle, // Use handle as is, without adding .bsky.social
collection: collection,
rkey: rkey,
record: recordData
}
setLastRecord(record)
// Direct API call with accessJwt
const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.putRecord`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessJwt}`
},
body: JSON.stringify(record)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(`API Error: ${response.status} - ${errorData.message || response.statusText}`)
}
const result = await response.json()
setSuccess(`Record created successfully! URI: ${result.uri}`)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleDelete = async () => {
if (!lastRecord || !accessJwt.trim()) {
setError('No record to delete or missing access JWT')
return
}
setLoading(true)
setError(null)
try {
const deleteData = {
repo: lastRecord.repo,
collection: lastRecord.collection,
rkey: lastRecord.rkey
}
const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.deleteRecord`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessJwt}`
},
body: JSON.stringify(deleteData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(`Delete Error: ${response.status} - ${errorData.message || response.statusText}`)
}
setSuccess('Record deleted successfully!')
setLastRecord(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="test-ui">
<h2>🧪 Test UI</h2>
{/* Tab Navigation */}
<div className="test-tabs">
<button
onClick={() => setActiveTab('putRecord')}
className={`test-tab ${activeTab === 'putRecord' ? 'active' : ''}`}
>
Manual putRecord
</button>
<button
onClick={() => setActiveTab('avatar')}
className={`test-tab ${activeTab === 'avatar' ? 'active' : ''}`}
>
Avatar System
</button>
</div>
{activeTab === 'putRecord' && (
<div className="test-content">
<p className="description">
OAuth不要のテスト用UIaccessJwtとhandleを直接入力して各collectionにダミーデータを投稿できます
</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="access-jwt">Access JWT:</label>
<textarea
id="access-jwt"
value={accessJwt}
onChange={(e) => setAccessJwt(e.target.value)}
placeholder="eyJ... (Access JWT token)"
rows={3}
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="handle">Handle:</label>
<input
id="handle"
type="text"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="user.bsky.social"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="session-did">Session DID (optional):</label>
<input
id="session-did"
type="text"
value={sessionDid}
onChange={(e) => setSessionDid(e.target.value)}
placeholder="did:plc:xxxxx (Leave empty to use test DID)"
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="collection">Collection:</label>
<select
id="collection"
value={collection}
onChange={(e) => setCollection(e.target.value)}
disabled={loading}
>
{collections.map(col => (
<option key={col} value={col}>{col}</option>
))}
</select>
</div>
{error && (
<div className="error-message">
{error}
</div>
)}
{success && (
<div className="success-message">
{success}
</div>
)}
<div className="form-actions">
<button
type="submit"
disabled={loading || !accessJwt.trim() || !handle.trim()}
className="submit-btn"
>
{loading ? '⏳ Creating...' : '📤 Create Record'}
</button>
<button
type="button"
onClick={() => setShowJson(!showJson)}
className="json-btn"
disabled={loading}
>
{showJson ? '🙈 Hide JSON' : '👁️ Show JSON'}
</button>
{lastRecord && (
<button
type="button"
onClick={handleDelete}
className="delete-btn"
disabled={loading}
>
{loading ? '⏳ Deleting...' : '🗑️ Delete Last Record'}
</button>
)}
</div>
</form>
{showJson && (
<div className="json-preview">
<h3>Generated JSON:</h3>
<pre>{JSON.stringify(generateDummyData(collection), null, 2)}</pre>
</div>
)}
{lastRecord && (
<div className="last-record">
<h3>Last Created Record:</h3>
<div className="record-info">
<p><strong>Collection:</strong> {lastRecord.collection}</p>
<p><strong>RKey:</strong> {lastRecord.rkey}</p>
<p><strong>Repo:</strong> {lastRecord.repo}</p>
</div>
</div>
)}
</div>
)}
{activeTab === 'avatar' && (
<div className="test-content">
<AvatarTestPanel />
</div>
)}
<style jsx>{`
.test-ui {
border: 3px solid #ff6b6b;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
background: #fff5f5;
}
.test-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
padding-bottom: 10px;
}
.test-tab {
background: #f8f9fa;
border: 1px solid #ddd;
padding: 8px 16px;
border-radius: 4px 4px 0 0;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: #666;
transition: all 0.2s;
}
.test-tab:hover {
background: #e9ecef;
color: #333;
}
.test-tab.active {
background: #ff6b6b;
color: white;
border-color: #ff6b6b;
}
.test-content {
margin-top: 20px;
}
.test-ui h2 {
color: #ff6b6b;
margin-top: 0;
}
.description {
color: #666;
font-style: italic;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #333;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
font-family: monospace;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #ff6b6b;
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.25);
}
.form-group input:disabled,
.form-group textarea:disabled,
.form-group select:disabled {
background: #f8f9fa;
cursor: not-allowed;
}
.error-message {
background: #f8d7da;
color: #721c24;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
border: 1px solid #f5c6cb;
}
.success-message {
background: #d4edda;
color: #155724;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
border: 1px solid #c3e6cb;
}
.form-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 20px;
}
.submit-btn {
background: #ff6b6b;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover:not(:disabled) {
background: #ff5252;
}
.submit-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
.json-btn {
background: #17a2b8;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.json-btn:hover:not(:disabled) {
background: #138496;
}
.delete-btn {
background: #dc3545;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.delete-btn:hover:not(:disabled) {
background: #c82333;
}
.json-preview {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.json-preview h3 {
margin-top: 0;
color: #495057;
}
.json-preview pre {
background: #e9ecef;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
margin: 0;
}
.last-record {
margin-top: 20px;
padding: 15px;
background: #e7f3ff;
border: 1px solid #b3d9ff;
border-radius: 4px;
}
.last-record h3 {
margin-top: 0;
color: #0066cc;
}
.record-info p {
margin: 5px 0;
font-family: monospace;
font-size: 14px;
}
`}</style>
</div>
)
}

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { getApiConfig } from '../utils/pds.js'
import { env } from '../config/env.js'
import { getErrorMessage, logError } from '../utils/errorHandler.js'
export function useAdminData() {
const [adminData, setAdminData] = useState({
@@ -14,6 +15,7 @@ export function useAdminData() {
const [commentRecords, setCommentRecords] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [retryCount, setRetryCount] = useState(0)
useEffect(() => {
loadAdminData()
@@ -38,9 +40,18 @@ export function useAdminData() {
setAdminData({ did, profile, records, apiConfig })
setLangRecords(lang)
setCommentRecords(comment)
setRetryCount(0) // 成功時はリトライカウントをリセット
} catch (err) {
console.error('Failed to load admin data:', err)
setError(err.message)
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)
}
@@ -52,6 +63,7 @@ export function useAdminData() {
commentRecords,
loading,
error,
retryCount,
refresh: loadAdminData
}
}

@@ -0,0 +1,234 @@
import { useState } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { env } from '../config/env.js'
import { logger } from '../utils/logger.js'
import { getErrorMessage, logError } from '../utils/errorHandler.js'
export function useAskAI(adminData, userProfile, agent) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [chatHistory, setChatHistory] = useState([])
// ask-AIサーバーのURL環境変数から取得、フォールバック付き
const askAIUrl = import.meta.env.VITE_ASK_AI_URL || 'http://localhost:3000/ask'
const askQuestion = async (question) => {
if (!question.trim()) return
setLoading(true)
setError(null)
try {
logger.log('Sending question to ask-AI:', question)
// ask-AIサーバーにリクエスト送信
const response = await fetch(askAIUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
question: question.trim(),
context: {
url: window.location.href,
timestamp: new Date().toISOString()
}
})
})
if (!response.ok) {
throw new Error(`ask-AI server error: ${response.status}`)
}
const aiResponse = await response.json()
logger.log('Received AI response:', aiResponse)
// AI回答をチャット履歴に追加
const chatEntry = {
id: `chat-${Date.now()}`,
question: question.trim(),
answer: aiResponse.answer || 'エラーが発生しました',
timestamp: new Date().toISOString(),
user: userProfile ? {
did: userProfile.did,
handle: userProfile.handle,
displayName: userProfile.displayName,
avatar: userProfile.avatar
} : null
}
setChatHistory(prev => [...prev, chatEntry])
// atprotoにレコードを保存
await saveChatRecord(chatEntry, aiResponse)
// Dispatch event for blog communication
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
question: chatEntry.question,
answer: chatEntry.answer,
timestamp: chatEntry.timestamp,
aiProfile: adminData?.profile ? {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
} : null
}
}))
return aiResponse
} catch (err) {
logError(err, 'useAskAI.askQuestion')
setError(getErrorMessage(err))
throw err
} finally {
setLoading(false)
}
}
const saveChatRecord = async (chatEntry, aiResponse) => {
if (!agent || !adminData?.did) {
logger.warn('Cannot save chat record: missing agent or admin data')
return
}
try {
const currentUrl = window.location.href
const timestamp = chatEntry.timestamp
const baseRkey = `${new Date(timestamp).toISOString().replace(/[:.]/g, '-').slice(0, -5)}Z`
// Post metadata (共通)
const postMetadata = {
url: currentUrl,
date: timestamp,
slug: new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
tags: [],
title: document.title || 'AI Chat',
language: 'ja'
}
// Question record (ユーザーの質問)
const questionRecord = {
repo: adminData.did,
collection: `${env.collection}.chat`,
rkey: baseRkey,
record: {
$type: `${env.collection}.chat`,
post: postMetadata,
text: chatEntry.question,
type: 'question',
author: chatEntry.user ? {
did: chatEntry.user.did,
handle: chatEntry.user.handle,
displayName: chatEntry.user.displayName,
avatar: chatEntry.user.avatar
} : {
did: 'unknown',
handle: 'user',
displayName: 'User',
avatar: null
},
createdAt: timestamp
}
}
// Answer record (AIの回答)
const answerRecord = {
repo: adminData.did,
collection: `${env.collection}.chat`,
rkey: `${baseRkey}-answer`,
record: {
$type: `${env.collection}.chat`,
post: postMetadata,
text: chatEntry.answer,
type: 'answer',
author: {
did: adminData.did,
handle: adminData.profile?.handle || 'ai',
displayName: adminData.profile?.displayName || 'ai',
avatar: adminData.profile?.avatar || null
},
createdAt: timestamp
}
}
logger.log('Saving question record to atproto:', questionRecord)
await atproto.putRecord(null, questionRecord, agent)
logger.log('Saving answer record to atproto:', answerRecord)
await atproto.putRecord(null, answerRecord, agent)
// キャッシュを無効化
collections.invalidateCache(env.collection)
logger.log('Chat records saved successfully')
} catch (err) {
logError(err, 'useAskAI.saveChatRecord')
// 保存エラーは致命的ではないので、UIエラーにはしない
}
}
const clearChatHistory = () => {
setChatHistory([])
setError(null)
}
const loadChatHistory = async () => {
if (!adminData?.did) return
try {
const records = await collections.getChat(
adminData.apiConfig.pds,
adminData.did,
env.collection
)
// Group records by timestamp and create Q&A pairs
const recordGroups = {}
records.forEach(record => {
const timestamp = record.value.createdAt
const baseKey = timestamp.replace('-answer', '')
if (!recordGroups[baseKey]) {
recordGroups[baseKey] = {}
}
if (record.value.type === 'question') {
recordGroups[baseKey].question = record.value.text
recordGroups[baseKey].user = record.value.author
recordGroups[baseKey].timestamp = timestamp
recordGroups[baseKey].id = record.uri
} else if (record.value.type === 'answer') {
recordGroups[baseKey].answer = record.value.text
recordGroups[baseKey].timestamp = timestamp
}
})
// Convert to history format, only include complete Q&A pairs
const history = Object.values(recordGroups)
.filter(group => group.question && group.answer)
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
.slice(-10) // 最新10件のみ
setChatHistory(history)
logger.log('Chat history loaded:', history.length, 'entries')
} catch (err) {
logError(err, 'useAskAI.loadChatHistory')
// 履歴読み込みエラーは致命的ではない
}
}
return {
askQuestion,
loading,
error,
chatHistory,
clearChatHistory,
loadChatHistory
}
}

@@ -24,8 +24,13 @@ export function useUserData(adminData) {
env.collection
)
// 2. Get chat records (ai.syui.log.chat doesn't exist, so skip for now)
setChatRecords([])
// 2. Get chat records from ai.syui.log.chat
const chatRecords = await collections.getChat(
adminData.apiConfig.pds,
adminData.did,
env.collection
)
setChatRecords(chatRecords)
// 3. Get base collection records which contain user comments
const baseRecords = await collections.getBase(

@@ -1,5 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './App.css'
ReactDOM.createRoot(document.getElementById('comment-atproto')).render(<App />)

@@ -66,11 +66,23 @@ export class OAuthService {
const did = session.sub || session.did
let handle = session.handle || 'unknown'
// Create Agent directly with session (per official docs)
try {
this.agent = new Agent(session)
} catch (err) {
// Fallback to dpopFetch method
this.agent = new Agent({
service: session.server?.serviceEndpoint || 'https://bsky.social',
fetch: session.dpopFetch
})
}
this.sessionInfo = { did, handle }
// Resolve handle if missing
if (handle === 'unknown' && this.agent) {
try {
await new Promise(resolve => setTimeout(resolve, 300))
const profile = await this.agent.getProfile({ actor: did })
handle = profile.data.handle
this.sessionInfo.handle = handle
@@ -86,7 +98,9 @@ export class OAuthService {
await this.initialize()
const client = isSyuIsHandle(handle) ? this.clients.syu : this.clients.bsky
const authUrl = await client.authorize(handle, { scope: 'atproto' })
const authUrl = await client.authorize(handle, {
scope: 'atproto transition:generic'
})
window.location.href = authUrl.toString()
}

@@ -0,0 +1,206 @@
import React from 'react'
import { atproto } from '../api/atproto.js'
import { getPdsFromHandle, getApiConfig } from './pds.js'
import { dataCache } from './cache.js'
import { logError } from './errorHandler.js'
// Cache duration for avatar URLs (30 minutes)
const AVATAR_CACHE_DURATION = 30 * 60 * 1000
/**
* Avatar fetching utility with fallback mechanism
*
* Strategy:
* 1. First check if avatar exists in the record
* 2. If avatar is missing/broken, fetch fresh data from ATProto
* 3. Cache results to avoid excessive API calls
*/
/**
* Extract avatar URL from record if available
* @param {Object} record - The record object
* @returns {string|null} Avatar URL or null
*/
function getAvatarFromRecord(record) {
const avatar = record?.value?.author?.avatar
if (avatar && typeof avatar === 'string' && avatar.startsWith('http')) {
return avatar
}
return null
}
/**
* Fetch fresh avatar data from ATProto
* @param {string} handle - User handle
* @param {string} did - User DID (optional, will fetch if not provided)
* @returns {Promise<string|null>} Avatar URL or null
*/
async function fetchFreshAvatar(handle, did = null) {
try {
// Step 1: Get PDS from handle
const pds = await getPdsFromHandle(handle)
const apiConfig = getApiConfig(pds)
// Step 2: Get DID if not provided
if (!did) {
const pdsHost = pds.replace(/^https?:\/\//, '')
const repoData = await atproto.getDid(pdsHost, handle)
did = repoData
}
// Step 3: Get profile from bsky API
const profile = await atproto.getProfile(apiConfig.bsky, did)
// Return avatar URL
return profile?.avatar || null
} catch (error) {
logError(error, 'Avatar Fetch')
return null
}
}
/**
* Get avatar with intelligent fallback
* @param {Object} options - Options object
* @param {Object} options.record - Record object (optional)
* @param {string} options.handle - User handle (required if no record)
* @param {string} options.did - User DID (optional)
* @param {boolean} options.forceFresh - Force fresh fetch even if cached
* @returns {Promise<string|null>} Avatar URL or null
*/
export async function getAvatar({ record, handle, did, forceFresh = false }) {
// Extract handle and DID from record if available
if (record && !handle) {
handle = record.value?.author?.handle
did = record.value?.author?.did
}
if (!handle) {
throw new Error('Handle is required to fetch avatar')
}
// Generate cache key
const cacheKey = `avatar:${handle}`
// Check cache first (unless forceFresh)
if (!forceFresh) {
const cached = dataCache.get(cacheKey)
if (cached) {
return cached
}
}
// Try to get avatar from record first
if (record) {
const recordAvatar = getAvatarFromRecord(record)
if (recordAvatar) {
// Validate that the avatar URL is still accessible
try {
const response = await fetch(recordAvatar, { method: 'HEAD' })
if (response.ok) {
dataCache.set(cacheKey, recordAvatar, AVATAR_CACHE_DURATION)
return recordAvatar
}
} catch {
// Avatar URL is broken, proceed to fetch fresh
}
}
}
// Fetch fresh avatar data
const freshAvatar = await fetchFreshAvatar(handle, did)
if (freshAvatar) {
dataCache.set(cacheKey, freshAvatar, AVATAR_CACHE_DURATION)
}
return freshAvatar
}
/**
* Batch fetch avatars for multiple users
* @param {Array<Object>} users - Array of user objects with handle/did
* @returns {Promise<Map>} Map of handle -> avatar URL
*/
export async function batchFetchAvatars(users) {
const avatarMap = new Map()
// Process in parallel with concurrency limit
const BATCH_SIZE = 5
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const batch = users.slice(i, i + BATCH_SIZE)
const promises = batch.map(async (user) => {
const avatar = await getAvatar({
handle: user.handle,
did: user.did
})
return { handle: user.handle, avatar }
})
const results = await Promise.all(promises)
results.forEach(({ handle, avatar }) => {
avatarMap.set(handle, avatar)
})
}
return avatarMap
}
/**
* Prefetch and cache avatar for a handle
* @param {string} handle - User handle
* @returns {Promise<void>}
*/
export async function prefetchAvatar(handle) {
await getAvatar({ handle })
}
/**
* Clear avatar cache for a specific handle
* @param {string} handle - User handle
*/
export function clearAvatarCache(handle) {
if (handle) {
dataCache.delete(`avatar:${handle}`)
}
}
/**
* Clear all avatar caches
*/
export function clearAllAvatarCaches() {
dataCache.invalidatePattern('avatar:')
}
/**
* React hook for avatar management
* @param {Object} options - Options for avatar fetching
* @returns {Object} { avatar, loading, error, refetch }
*/
export function useAvatar({ record, handle, did }) {
const [state, setState] = React.useState({
avatar: null,
loading: true,
error: null
})
const fetchAvatar = React.useCallback(async (forceFresh = false) => {
setState(prev => ({ ...prev, loading: true, error: null }))
try {
const avatarUrl = await getAvatar({ record, handle, did, forceFresh })
setState({ avatar: avatarUrl, loading: false, error: null })
} catch (error) {
setState({ avatar: null, loading: false, error: error.message })
}
}, [record, handle, did])
React.useEffect(() => {
fetchAvatar()
}, [fetchAvatar])
return {
...state,
refetch: () => fetchAvatar(true)
}
}

@@ -0,0 +1,262 @@
import { dataCache } from './cache.js'
/**
* Avatar-specific cache utilities
* Extends the base cache system with avatar-specific functionality
*/
// Cache keys
const CACHE_PREFIX = 'avatar:'
const METADATA_KEY = 'avatar:metadata'
/**
* Get cache metadata for avatars
* @returns {Object} Metadata about avatar cache
*/
export function getAvatarCacheMetadata() {
return dataCache.get(METADATA_KEY) || {
totalCount: 0,
lastCleanup: Date.now(),
cacheHits: 0,
cacheMisses: 0
}
}
/**
* Update cache metadata
* @param {Object} updates - Updates to apply to metadata
*/
function updateMetadata(updates) {
const current = getAvatarCacheMetadata()
const updated = { ...current, ...updates }
dataCache.set(METADATA_KEY, updated)
}
/**
* Track cache hit
*/
export function trackCacheHit() {
const metadata = getAvatarCacheMetadata()
updateMetadata({ cacheHits: metadata.cacheHits + 1 })
}
/**
* Track cache miss
*/
export function trackCacheMiss() {
const metadata = getAvatarCacheMetadata()
updateMetadata({ cacheMisses: metadata.cacheMisses + 1 })
}
/**
* Get all cached avatar handles
* @returns {Array<string>} List of cached handles
*/
export function getCachedAvatarHandles() {
// This would require enumerating cache keys
// For now, we'll track this in metadata
const metadata = getAvatarCacheMetadata()
return metadata.handles || []
}
/**
* Add handle to cached list
* @param {string} handle - Handle to add
*/
export function addCachedHandle(handle) {
const metadata = getAvatarCacheMetadata()
const handles = metadata.handles || []
if (!handles.includes(handle)) {
handles.push(handle)
updateMetadata({
handles,
totalCount: handles.length
})
}
}
/**
* Remove handle from cached list
* @param {string} handle - Handle to remove
*/
export function removeCachedHandle(handle) {
const metadata = getAvatarCacheMetadata()
const handles = (metadata.handles || []).filter(h => h !== handle)
updateMetadata({
handles,
totalCount: handles.length
})
}
/**
* Clean up expired avatar cache entries
* @param {number} maxAge - Maximum age in milliseconds (default: 30 minutes)
* @returns {number} Number of entries cleaned
*/
export function cleanupExpiredAvatars(maxAge = 30 * 60 * 1000) {
const now = Date.now()
const metadata = getAvatarCacheMetadata()
const handles = metadata.handles || []
let cleanedCount = 0
handles.forEach(handle => {
const cacheKey = `${CACHE_PREFIX}${handle}`
const entry = dataCache.get(cacheKey, true) // Get with metadata
if (entry && entry.timestamp && (now - entry.timestamp) > maxAge) {
dataCache.delete(cacheKey)
cleanedCount++
}
})
// Update metadata
if (cleanedCount > 0) {
const remainingHandles = handles.filter(handle => {
const cacheKey = `${CACHE_PREFIX}${handle}`
return dataCache.get(cacheKey) !== null
})
updateMetadata({
handles: remainingHandles,
totalCount: remainingHandles.length,
lastCleanup: now
})
}
return cleanedCount
}
/**
* Get cache statistics
* @returns {Object} Cache statistics
*/
export function getAvatarCacheStats() {
const metadata = getAvatarCacheMetadata()
const totalRequests = metadata.cacheHits + metadata.cacheMisses
const hitRate = totalRequests > 0 ? (metadata.cacheHits / totalRequests * 100) : 0
return {
totalCached: metadata.totalCount || 0,
cacheHits: metadata.cacheHits || 0,
cacheMisses: metadata.cacheMisses || 0,
hitRate: Math.round(hitRate * 100) / 100,
lastCleanup: metadata.lastCleanup ? new Date(metadata.lastCleanup) : null
}
}
/**
* Clear all avatar cache data
* @returns {number} Number of entries cleared
*/
export function clearAllAvatarCache() {
const metadata = getAvatarCacheMetadata()
const handles = metadata.handles || []
handles.forEach(handle => {
const cacheKey = `${CACHE_PREFIX}${handle}`
dataCache.delete(cacheKey)
})
// Clear metadata
dataCache.delete(METADATA_KEY)
return handles.length
}
/**
* Preload avatars for a list of handles
* @param {Array<string>} handles - Handles to preload
* @param {Function} getAvatar - Avatar fetching function
* @returns {Promise<Map>} Map of handle -> avatar URL results
*/
export async function preloadAvatars(handles, getAvatar) {
const results = new Map()
const BATCH_SIZE = 3 // Smaller batch for preloading
for (let i = 0; i < handles.length; i += BATCH_SIZE) {
const batch = handles.slice(i, i + BATCH_SIZE)
const promises = batch.map(async (handle) => {
try {
const avatar = await getAvatar({ handle })
return { handle, avatar, success: true }
} catch (error) {
return { handle, avatar: null, success: false, error: error.message }
}
})
const batchResults = await Promise.all(promises)
batchResults.forEach(({ handle, avatar, success }) => {
results.set(handle, { avatar, success })
if (success) {
addCachedHandle(handle)
}
})
// Small delay between batches to avoid overwhelming the API
if (i + BATCH_SIZE < handles.length) {
await new Promise(resolve => setTimeout(resolve, 100))
}
}
return results
}
/**
* Validate cached avatar URLs
* Check if cached avatar URLs are still valid
* @param {number} sampleSize - Number of cached avatars to validate (default: 5)
* @returns {Promise<Object>} Validation results
*/
export async function validateCachedAvatars(sampleSize = 5) {
const metadata = getAvatarCacheMetadata()
const handles = metadata.handles || []
if (handles.length === 0) {
return { validCount: 0, invalidCount: 0, totalChecked: 0 }
}
// Sample random handles to check
const samplesToCheck = handles
.sort(() => Math.random() - 0.5)
.slice(0, sampleSize)
let validCount = 0
let invalidCount = 0
for (const handle of samplesToCheck) {
const cacheKey = `${CACHE_PREFIX}${handle}`
const avatarUrl = dataCache.get(cacheKey)
if (avatarUrl && typeof avatarUrl === 'string' && avatarUrl.startsWith('http')) {
try {
const response = await fetch(avatarUrl, { method: 'HEAD' })
if (response.ok) {
validCount++
} else {
invalidCount++
// Remove invalid cached avatar
dataCache.delete(cacheKey)
removeCachedHandle(handle)
}
} catch {
invalidCount++
// Remove invalid cached avatar
dataCache.delete(cacheKey)
removeCachedHandle(handle)
}
} else {
invalidCount++
// Remove invalid cache entry
dataCache.delete(cacheKey)
removeCachedHandle(handle)
}
}
return {
validCount,
invalidCount,
totalChecked: samplesToCheck.length,
validationRate: samplesToCheck.length > 0 ?
Math.round((validCount / samplesToCheck.length) * 100) : 0
}
}

@@ -0,0 +1,147 @@
import { getPdsFromHandle, getApiConfig } from './pds.js'
import { logger } from './logger.js'
// Avatar取得の状態管理
const avatarCache = new Map()
const CACHE_DURATION = 30 * 60 * 1000 // 30分
// Avatar URLが有効かチェック
async function isAvatarValid(avatarUrl) {
if (!avatarUrl) return false
try {
const response = await fetch(avatarUrl, { method: 'HEAD' })
return response.ok
} catch (error) {
logger.warn('Avatar URL check failed:', error)
return false
}
}
// handleからDIDを取得
async function getDid(handle) {
try {
const pds = await getPdsFromHandle(handle)
const response = await fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`)
const data = await response.json()
return data.did
} catch (error) {
logger.error('Failed to get DID for handle:', handle, error)
throw error
}
}
// DIDからプロフィール情報を取得
async function getProfile(did, handle) {
try {
// Determine which public API to use based on handle
const pds = await getPdsFromHandle(handle)
const apiConfig = getApiConfig(pds)
// Use the appropriate public API endpoint
const publicApiUrl = apiConfig.bsky
logger.log('Getting profile for DID:', did, 'using public API:', publicApiUrl)
const response = await fetch(`${publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${did}`)
if (!response.ok) {
throw new Error(`Profile API error: ${response.status} ${response.statusText}`)
}
const data = await response.json()
logger.log('Profile data received:', data)
return data
} catch (error) {
logger.error('Failed to get profile for DID:', did, error)
throw error
}
}
// 新しいavatar URLを取得
async function fetchFreshAvatar(handle, did) {
const cacheKey = `${handle}:${did || 'no-did'}`
const cached = avatarCache.get(cacheKey)
// キャッシュチェック
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
logger.log('Using cached avatar for:', handle)
return cached.avatar
}
try {
logger.log('Fetching fresh avatar for handle:', handle, 'with DID:', did)
// DIDが不明な場合は取得
let actualDid = did
if (!actualDid) {
logger.log('No DID provided, fetching from handle:', handle)
actualDid = await getDid(handle)
logger.log('Got DID from handle:', actualDid)
}
// プロフィール取得
const profile = await getProfile(actualDid, handle)
const avatarUrl = profile.avatar || null
// キャッシュに保存
avatarCache.set(cacheKey, {
avatar: avatarUrl,
timestamp: Date.now(),
profile: {
displayName: profile.displayName,
handle: profile.handle
}
})
logger.log('Fresh avatar fetched for:', handle, 'Avatar URL:', avatarUrl)
return avatarUrl
} catch (error) {
logger.error('Failed to fetch fresh avatar for:', handle, 'Error:', error)
return null
}
}
// メイン関数: avatarを取得recordから → 新規取得)
export async function getValidAvatar(record) {
const author = record?.value?.author
if (!author?.handle) {
logger.warn('No handle found in record author')
return null
}
const { handle, did, avatar: recordAvatar } = author
// 1. record内のavatarをチェック
if (recordAvatar) {
const isValid = await isAvatarValid(recordAvatar)
if (isValid) {
logger.log('Using avatar from record:', recordAvatar)
return recordAvatar
} else {
logger.log('Record avatar is broken, fetching fresh:', recordAvatar)
}
}
// 2. 新しいavatarを取得
return await fetchFreshAvatar(handle, did)
}
// キャッシュクリア
export function clearAvatarCache() {
avatarCache.clear()
logger.log('Avatar cache cleared')
}
// キャッシュ統計
export function getAvatarCacheStats() {
return {
size: avatarCache.size,
entries: Array.from(avatarCache.entries()).map(([key, value]) => ({
key,
avatar: value.avatar,
age: Date.now() - value.timestamp,
profile: value.profile
}))
}
}

@@ -0,0 +1,63 @@
import { logger } from './logger.js'
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
}
logger.log(`Cache hit: ${key}`)
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
logger.log(`Cache set: ${key}`)
}
clear() {
this.cache.clear()
logger.log('Cache cleared')
}
invalidatePattern(pattern) {
let deletedCount = 0
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
deletedCount++
}
}
logger.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
}

@@ -0,0 +1,49 @@
import { logger } from './logger.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) 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
}
logger.error(`[ATProto Error] ${context}:`, errorInfo)
// 本番環境では外部ログサービスに送信することも可能
// if (import.meta.env.PROD) {
// sendToLogService(errorInfo)
// }
}

@@ -0,0 +1,82 @@
// Logger utility with environment-based control
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 // Enable only in dev AND when debug flag is true
}
log(...args) {
if (this.isEnabled) {
console.log(...args)
}
}
error(...args) {
if (this.isEnabled) {
console.error(...args)
}
}
warn(...args) {
if (this.isEnabled) {
console.warn(...args)
}
}
info(...args) {
if (this.isEnabled) {
console.info(...args)
}
}
// グループログ
group(label) {
if (this.isEnabled) {
console.group(label)
}
}
groupEnd() {
if (this.isEnabled) {
console.groupEnd()
}
}
// テーブル表示
table(data) {
if (this.isEnabled) {
console.table(data)
}
}
// 時間計測
time(label) {
if (this.isEnabled) {
console.time(label)
}
}
timeEnd(label) {
if (this.isEnabled) {
console.timeEnd(label)
}
}
// ログを有効/無効にする
enable() {
this.isEnabled = true
}
disable() {
this.isEnabled = false
}
}
// シングルトンインスタンス
export const logger = new Logger()
// 開発環境でのみグローバルアクセス可能にする
if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_DEBUG === 'true') {
window._logger = logger
}

@@ -64,6 +64,10 @@ case "${1:-serve}" in
oauth|o)
_oauth_build
;;
n)
oauth=$d/oauth_new
_oauth_build
;;
comment|co)
_server_comment
;;