From b54e8089ea0870da7390a6512f537adf4731ccee Mon Sep 17 00:00:00 2001 From: syui Date: Wed, 18 Jun 2025 17:25:42 +0900 Subject: [PATCH] oauth_new --- .claude/settings.local.json | 3 +- my-blog/oauth/.env.production | 2 +- oauth_new/ASK_AI_INTEGRATION.md | 116 +++ oauth_new/AVATAR_SYSTEM.md | 174 ++++ oauth_new/AVATAR_USAGE_EXAMPLES.md | 420 +++++++++ oauth_new/build-minimal.js | 25 + oauth_new/json/ai.syui.ai_chat_comment.json | 62 ++ oauth_new/json/ai.syui.ai_chat_lang.json | 62 ++ oauth_new/json/ai.syui.ai_log.json | 30 + oauth_new/json/ai.syui.ai_user.json | 53 ++ oauth_new/json/syui.syui.ai_chat.json | 53 ++ oauth_new/json/syui.syui.ai_log.json | 3 + oauth_new/package.json | 2 +- oauth_new/src/App.css | 846 +++++++++++++++++++ oauth_new/src/App.jsx | 156 +++- oauth_new/src/api/atproto.js | 105 ++- oauth_new/src/components/AskAI.jsx | 399 +++++++++ oauth_new/src/components/AuthButton.jsx | 105 +-- oauth_new/src/components/Avatar.jsx | 234 +++++ oauth_new/src/components/AvatarImage.jsx | 103 +++ oauth_new/src/components/AvatarTest.jsx | 203 +++++ oauth_new/src/components/AvatarTestPanel.jsx | 246 ++++++ oauth_new/src/components/CommentForm.jsx | 135 +-- oauth_new/src/components/LoadingSkeleton.jsx | 98 +++ oauth_new/src/components/RecordList.jsx | 139 ++- oauth_new/src/components/RecordTabs.jsx | 142 ++-- oauth_new/src/components/TestUI.jsx | 531 ++++++++++++ oauth_new/src/hooks/useAdminData.js | 16 +- oauth_new/src/hooks/useAskAI.js | 234 +++++ oauth_new/src/hooks/useUserData.js | 9 +- oauth_new/src/main.jsx | 1 + oauth_new/src/utils/avatar.js | 206 +++++ oauth_new/src/utils/avatarCache.js | 262 ++++++ oauth_new/src/utils/avatarFetcher.js | 143 ++++ oauth_new/src/utils/cache.js | 63 ++ oauth_new/src/utils/errorHandler.js | 49 ++ oauth_new/src/utils/logger.js | 81 ++ scpt/run.zsh | 4 + 38 files changed, 5192 insertions(+), 323 deletions(-) create mode 100644 oauth_new/ASK_AI_INTEGRATION.md create mode 100644 oauth_new/AVATAR_SYSTEM.md create mode 100644 oauth_new/AVATAR_USAGE_EXAMPLES.md create mode 100644 oauth_new/build-minimal.js create mode 100644 oauth_new/json/ai.syui.ai_chat_comment.json create mode 100644 oauth_new/json/ai.syui.ai_chat_lang.json create mode 100644 oauth_new/json/ai.syui.ai_log.json create mode 100644 oauth_new/json/ai.syui.ai_user.json create mode 100644 oauth_new/json/syui.syui.ai_chat.json create mode 100644 oauth_new/json/syui.syui.ai_log.json create mode 100644 oauth_new/src/App.css create mode 100644 oauth_new/src/components/AskAI.jsx create mode 100644 oauth_new/src/components/Avatar.jsx create mode 100644 oauth_new/src/components/AvatarImage.jsx create mode 100644 oauth_new/src/components/AvatarTest.jsx create mode 100644 oauth_new/src/components/AvatarTestPanel.jsx create mode 100644 oauth_new/src/components/LoadingSkeleton.jsx create mode 100644 oauth_new/src/components/TestUI.jsx create mode 100644 oauth_new/src/hooks/useAskAI.js create mode 100644 oauth_new/src/utils/avatar.js create mode 100644 oauth_new/src/utils/avatarCache.js create mode 100644 oauth_new/src/utils/avatarFetcher.js create mode 100644 oauth_new/src/utils/cache.js create mode 100644 oauth_new/src/utils/errorHandler.js create mode 100644 oauth_new/src/utils/logger.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4b4798a..3dc08fb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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": [] } diff --git a/my-blog/oauth/.env.production b/my-blog/oauth/.env.production index e5e32d2..7866320 100644 --- a/my-blog/oauth/.env.production +++ b/my-blog/oauth/.env.production @@ -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とか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" diff --git a/oauth_new/ASK_AI_INTEGRATION.md b/oauth_new/ASK_AI_INTEGRATION.md new file mode 100644 index 0000000..23af6c8 --- /dev/null +++ b/oauth_new/ASK_AI_INTEGRATION.md @@ -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" +} +``` + +## イベント通信 + +blog(ask-ai.js)と OAuth アプリ間の通信: + +### 送信イベント +- `postAIQuestion` - blog から OAuth アプリへ質問送信 +- `aiProfileLoaded` - OAuth アプリから blog へ AI プロフィール送信 +- `aiResponseReceived` - OAuth アプリから blog へ AI 回答送信 + +### 受信イベント +- OAuth アプリが `postAIQuestion` を受信して処理 +- blog が `aiResponseReceived` を受信して表示 + +## 環境変数 + +```env +VITE_ASK_AI_URL=http://localhost:3000/ask # ask-AI サーバーURL(デフォルト) +VITE_ADMIN_HANDLE=ai.syui.ai +VITE_ATPROTO_PDS=syu.is +VITE_OAUTH_COLLECTION=ai.syui.log +``` + +## 機能 + +### 実装済み +- ✅ ask-AI サーバーとの通信 +- ✅ atproto への question/answer record 保存 +- ✅ チャット履歴の表示・管理 +- ✅ blog との双方向イベント通信 +- ✅ 認証機能(ログイン必須) +- ✅ エラーハンドリング・ローディング状態 +- ✅ 実際の JSON 構造に合わせた実装 + +### 今後のテスト項目 +- ask-AI サーバーの準備・起動 +- 実際の質問送信テスト +- atproto への putRecord 動作確認 +- blog からの連携テスト + +## 使用方法 + +1. 開発サーバー起動: `npm run dev` +2. OAuth ログイン実行 +3. "Ask AI" ボタンをクリック +4. チャット画面で質問入力 +5. AI の回答が表示され、atproto に記録される + +## 注意事項 + +- ask-AI サーバー(VITE_ASK_AI_URL)が必要 +- 認証されたユーザーのみ質問可能 +- ai.syui.log.chat への書き込み権限が必要 +- Production 環境では logger が無効化される \ No newline at end of file diff --git a/oauth_new/AVATAR_SYSTEM.md b/oauth_new/AVATAR_SYSTEM.md new file mode 100644 index 0000000..9473403 --- /dev/null +++ b/oauth_new/AVATAR_SYSTEM.md @@ -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 + +### `` +Basic avatar component with loading states and fallbacks. + +```jsx + console.log('loaded')} + onError={(err) => console.log('error', err)} +/> +``` + +### `` +Avatar with hover card showing user information. + +```jsx + +``` + +### `` +Display multiple avatars with overlap effect. + +```jsx + +``` + +## 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 && ( + avatar +)} + +// New approach + +``` + +## 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 \ No newline at end of file diff --git a/oauth_new/AVATAR_USAGE_EXAMPLES.md b/oauth_new/AVATAR_USAGE_EXAMPLES.md new file mode 100644 index 0000000..a6461f1 --- /dev/null +++ b/oauth_new/AVATAR_USAGE_EXAMPLES.md @@ -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 ( +
+ +

{user.displayName}

+
+ ) +} +``` + +### Avatar from Record Data + +```jsx +function CommentItem({ record }) { + return ( +
+ +
+ {record.value.author.displayName} +

{record.value.text}

+
+
+ ) +} +``` + +### Avatar with Hover Card + +```jsx +import { AvatarWithCard } from './components/Avatar.jsx' + +function UserList({ users, apiConfig }) { + return ( +
+ {users.map(user => ( + + ))} +
+ ) +} +``` + +## 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
Loading team...
+ + return ( +
+ {team.members.map(member => ( + {member.displayName} + ))} +
+ ) +} +``` + +### 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 ( +
+ + +
+ ) +} +``` + +### Avatar List with Overflow + +```jsx +import { AvatarList } from './components/Avatar.jsx' + +function ParticipantsList({ participants, maxVisible = 5 }) { + return ( +
+

Participants ({participants.length})

+ + {participants.length > maxVisible && ( + + and {participants.length - maxVisible} more... + + )} +
+ ) +} +``` + +## 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 ( + Fallback avatar setHasError(false)} // Reset on fallback error + /> + ) + } + + return ( + + ) +} +``` + +### 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
Loading...
+ } + + if (error) { + return
Failed to load avatar
+ } + + if (!avatar) { + return
No avatar
+ } + + return 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 ( +
+ {isVisible && ( + + )} +

{user.displayName}

+
+ ) +} +``` + +### 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 ( +
+ {isVisible ? ( + + ) : ( +
+ )} +
+ ) +} +``` + +## 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 ( +
+

Avatar Cache Stats

+

Cached avatars: {stats.totalCached}

+

Cache hit rate: {stats.hitRate}%

+

Cache hits: {stats.cacheHits}

+

Cache misses: {stats.cacheMisses}

+ +
+ ) +} +``` + +## 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 ( +
+ {initial} +
+ ) +} + +// 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. \ No newline at end of file diff --git a/oauth_new/build-minimal.js b/oauth_new/build-minimal.js new file mode 100644 index 0000000..5ebfbc8 --- /dev/null +++ b/oauth_new/build-minimal.js @@ -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(/]*src="([^"]*)"[^>]*><\/script>/) +const linkMatch = content.match(/]*href="([^"]*)"[^>]*>/) + +if (scriptMatch && linkMatch) { + const minimalContent = ` + + +` + + fs.writeFileSync(indexPath, minimalContent) + console.log('Generated minimal index.html') +} else { + console.error('Could not extract asset references') +} \ No newline at end of file diff --git a/oauth_new/json/ai.syui.ai_chat_comment.json b/oauth_new/json/ai.syui.ai_chat_comment.json new file mode 100644 index 0000000..afba414 --- /dev/null +++ b/oauth_new/json/ai.syui.ai_chat_comment.json @@ -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" +} diff --git a/oauth_new/json/ai.syui.ai_chat_lang.json b/oauth_new/json/ai.syui.ai_chat_lang.json new file mode 100644 index 0000000..20d6f1e --- /dev/null +++ b/oauth_new/json/ai.syui.ai_chat_lang.json @@ -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! I’m Ai, and I made a super cool blog! It’s called “ailog,” and it’s 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.” It’s 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`. It’s 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! It’s 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! It’s like having a really, really good friend who knows everything! (But it's still changing, so maybe it won’t 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 (Don’t Worry, I’ll 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! Isn’t that amazing?!\n\n**Testing, Testing, 1, 2, 3!**\n\nI need to test my blog to make sure everything works perfectly. I’ll 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 Ai’s 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, here’s the translation of the blog post, written as if it’s 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 – it’s like a time capsule! But now it’s on Cloudflare Pages! It’s super shiny! \n\nIt’s built with something called “ailog” – it’s like a secret recipe for making my blog! \n\n**Here’s how it works (it’s kinda magic!)**\n\n1. **Checking Out:** It’s like, “Hey, let’s look at all the files!” (This is the `actions/checkout@v4` part – it’s like a super-fast peek!)\n\n2. **Rust Time!** It needs Rust – it’s 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. It’s 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?! I’m so good at computers! I even know about tiny things, like…uh…well, never mind! 😉 It’s super cool! 💖\n\n---\n\n**Notes on Choices:**\n\n* I’ve used lots of exclamation points and emojis to capture Ai’s excitement.\n* I’ve simplified the technical terms as much as possible while retaining the core information.\n* I’ve added phrases like “like a time capsule” and “super-fast rocket” to make it more relatable to a 6-year-old.\n* I’ve kept the code blocks as they are, as they’re 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" +} diff --git a/oauth_new/json/ai.syui.ai_log.json b/oauth_new/json/ai.syui.ai_log.json new file mode 100644 index 0000000..171c549 --- /dev/null +++ b/oauth_new/json/ai.syui.ai_log.json @@ -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" +} diff --git a/oauth_new/json/ai.syui.ai_user.json b/oauth_new/json/ai.syui.ai_user.json new file mode 100644 index 0000000..d2b86b7 --- /dev/null +++ b/oauth_new/json/ai.syui.ai_user.json @@ -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" +} diff --git a/oauth_new/json/syui.syui.ai_chat.json b/oauth_new/json/syui.syui.ai_chat.json new file mode 100644 index 0000000..2f8fda0 --- /dev/null +++ b/oauth_new/json/syui.syui.ai_chat.json @@ -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" +} diff --git a/oauth_new/json/syui.syui.ai_log.json b/oauth_new/json/syui.syui.ai_log.json new file mode 100644 index 0000000..aa38702 --- /dev/null +++ b/oauth_new/json/syui.syui.ai_log.json @@ -0,0 +1,3 @@ +{ + "records": [] +} diff --git a/oauth_new/package.json b/oauth_new/package.json index e0ead1d..637d16a 100644 --- a/oauth_new/package.json +++ b/oauth_new/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node build-minimal.js", "preview": "vite preview" }, "dependencies": { diff --git a/oauth_new/src/App.css b/oauth_new/src/App.css new file mode 100644 index 0000000..4713471 --- /dev/null +++ b/oauth_new/src/App.css @@ -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; +} \ No newline at end of file diff --git a/oauth_new/src/App.jsx b/oauth_new/src/App.jsx index 598db75..a0ea6e2 100644 --- a/oauth_new/src/App.jsx +++ b/oauth_new/src/App.jsx @@ -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,55 @@ 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) + + // 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=')) { @@ -34,8 +76,32 @@ export default function App() { return (

ATProto OAuth Demo

-

エラー: {error}

-
@@ -43,34 +109,66 @@ export default function App() { } return ( -
-
-

ATProto OAuth Demo

- +
+
+
+
+ +
+
- { - refreshAdminData?.() - refreshUserData?.() - }} - /> +
+
- +
+ { + refreshAdminData?.() + refreshUserData?.() + }} + /> +
+ + { + refreshAdminData?.() + refreshUserData?.() + }} + /> + + {showTestUI && ( +
+ +
+ )} + +
+ +
+
+
) } \ No newline at end of file diff --git a/oauth_new/src/api/atproto.js b/oauth_new/src/api/atproto.js index 6a65249..584f8cc 100644 --- a/oauth_new/src/api/atproto.js +++ b/oauth_new/src/api/atproto.js @@ -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) } } \ No newline at end of file diff --git a/oauth_new/src/components/AskAI.jsx b/oauth_new/src/components/AskAI.jsx new file mode 100644 index 0000000..b929574 --- /dev/null +++ b/oauth_new/src/components/AskAI.jsx @@ -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) => ( +
+ {/* ユーザーの質問 */} +
+
+
+ {entry.user?.avatar ? ( + {entry.user.displayName} + ) : ( + '👤' + )} +
+
+
{entry.user?.displayName || 'You'}
+
@{entry.user?.handle || 'user'}
+
{formatTimestamp(entry.timestamp)}
+
+
+
{entry.question}
+
+ + {/* AIの回答 */} +
+
+
+ {adminData?.profile?.avatar ? ( + {adminData.profile.displayName} + ) : ( + '🤖' + )} +
+
+
{adminData?.profile?.displayName || 'AI'}
+
@{adminData?.profile?.handle || 'ai'}
+
{formatTimestamp(entry.timestamp)}
+
+
+
{entry.answer}
+
+
+ ) + + return ( +
+
+

Ask AI

+
+ + +
+
+ +
+ {chatHistory.length === 0 && !loading ? ( +
+
+
+
+ {adminData?.profile?.avatar ? ( + {adminData.profile.displayName} + ) : ( + '🤖' + )} +
+
+
{adminData?.profile?.displayName || 'AI'}
+
@{adminData?.profile?.handle || 'ai'}
+
+
+
+ こんにちは!このブログの内容について何でも質問してください。記事の詳細や関連する話題について説明できます。 +
+
+
+ ) : ( + chatHistory.map(renderMessage) + )} + + {loading && ( +
+
+
🤖
+
+
考え中...
+
+
+ +
+ )} + + {error && ( +
+
+ エラー: {error} +
+
+ )} + +
+
+ +
+
+